@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.
Files changed (214) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/package.json +18 -7
  3. package/src/client/browser.js +21 -0
  4. package/src/client/conversation/AssistantAttachmentQueue.vue +25 -108
  5. package/src/client/conversation/AssistantComposerActions.vue +1 -1
  6. package/src/client/conversation/AssistantConversationElement.vue +14 -52
  7. package/src/client/conversation/AssistantGoalControl.vue +18 -12
  8. package/src/client/conversation/AssistantPromptInput.vue +6 -91
  9. package/src/client/conversation/AssistantTranscript.css +505 -0
  10. package/src/client/conversation/AssistantTranscript.vue +24 -1197
  11. package/src/client/conversation/attachmentFiles.js +158 -0
  12. package/src/client/conversation/attachmentQueueItems.js +121 -0
  13. package/src/client/conversation/attachmentUploadRows.js +265 -0
  14. package/src/client/conversation/conversationDisplay.js +54 -0
  15. package/src/client/conversation/messageDelivery.js +13 -9
  16. package/src/client/conversation/retainedConversation.js +14 -3
  17. package/src/client/conversation/submitText.js +13 -2
  18. package/src/client/conversation/transcriptEntries.js +225 -0
  19. package/src/client/conversation/useAssistantAttachments.js +86 -388
  20. package/src/client/conversation/useAssistantQuestions.js +16 -3
  21. package/src/client/conversation/useAssistantSuggestions.js +16 -2
  22. package/src/client/conversation/useScrollToBottom.js +17 -19
  23. package/src/client/conversation/useTextareaAutoGrow.js +115 -0
  24. package/src/client/conversation/useTranscriptScrollFollow.js +525 -0
  25. package/src/client/lib/assistantApi.js +192 -161
  26. package/src/client/types.js +110 -0
  27. package/src/server/conversation/attachments.js +21 -6
  28. package/src/server/conversation/boundedToolLoop.js +1 -0
  29. package/src/server/conversation/claudeClient.js +71 -20
  30. package/src/server/conversation/claudeCommandRun.js +191 -0
  31. package/src/server/conversation/claudeHistory.js +7 -0
  32. package/src/server/conversation/claudeNativeTurn.js +708 -0
  33. package/src/server/conversation/claudeProcess.js +186 -112
  34. package/src/server/conversation/claudeProcessStart.js +214 -0
  35. package/src/server/conversation/claudePromptDelivery.js +183 -0
  36. package/src/server/conversation/claudeSessionCleanup.js +248 -0
  37. package/src/server/conversation/claudeTools.js +15 -3
  38. package/src/server/conversation/claudeTurn.js +212 -1388
  39. package/src/server/conversation/codexAccountReader.js +168 -0
  40. package/src/server/conversation/codexAuthPreflight.js +5 -4
  41. package/src/server/conversation/codexClient.js +18 -6
  42. package/src/server/conversation/codexConfiguration.js +8 -844
  43. package/src/server/conversation/codexConversationStops.js +377 -0
  44. package/src/server/conversation/codexConversationStore.js +119 -85
  45. package/src/server/conversation/codexConversationTurns.js +378 -0
  46. package/src/server/conversation/codexConversations.js +134 -866
  47. package/src/server/conversation/codexDetachedConversation.js +203 -0
  48. package/src/server/conversation/codexDetachedTurn.js +145 -104
  49. package/src/server/conversation/codexEvents.js +5 -200
  50. package/src/server/conversation/codexGoalCommands.js +14 -4
  51. package/src/server/conversation/codexHelperExecution.js +57 -57
  52. package/src/server/conversation/codexHelperThreadLedger.js +30 -288
  53. package/src/server/conversation/codexHelperThreadRecord.js +304 -0
  54. package/src/server/conversation/codexHelperThreadRecovery.js +54 -76
  55. package/src/server/conversation/codexHelperThreads.js +94 -93
  56. package/src/server/conversation/codexHistoryAdapter.js +13 -8
  57. package/src/server/conversation/codexIsolation.js +672 -0
  58. package/src/server/conversation/codexLiveItems.js +379 -0
  59. package/src/server/conversation/codexMessageCommands.js +68 -286
  60. package/src/server/conversation/codexMessageDelivery.js +46 -37
  61. package/src/server/conversation/codexModelCatalog.js +86 -7
  62. package/src/server/conversation/codexNotificationObserver.js +81 -88
  63. package/src/server/conversation/codexNotificationQueue.js +21 -10
  64. package/src/server/conversation/codexObservationControl.js +105 -89
  65. package/src/server/conversation/codexOutput.js +84 -419
  66. package/src/server/conversation/codexProcess.js +29 -1053
  67. package/src/server/conversation/codexProcessLeader.js +1 -0
  68. package/src/server/conversation/codexPromptDispatch.js +241 -0
  69. package/src/server/conversation/codexProvider.js +147 -1807
  70. package/src/server/conversation/codexProviderAuth.js +348 -0
  71. package/src/server/conversation/codexProviderInvalidation.js +371 -0
  72. package/src/server/conversation/codexProviderInventories.js +437 -0
  73. package/src/server/conversation/codexProviderOwner.js +149 -957
  74. package/src/server/conversation/codexProviderRelease.js +465 -0
  75. package/src/server/conversation/codexProviderSelection.js +26 -15
  76. package/src/server/conversation/codexProviderThreads.js +698 -0
  77. package/src/server/conversation/codexProviderTurns.js +453 -0
  78. package/src/server/conversation/codexReasoning.js +32 -23
  79. package/src/server/conversation/codexRenewalCommands.js +18 -6
  80. package/src/server/conversation/codexRenewalThreads.js +7 -22
  81. package/src/server/conversation/codexResultSettlement.js +70 -88
  82. package/src/server/conversation/codexRunJournal.js +91 -97
  83. package/src/server/conversation/codexRuntime.js +426 -0
  84. package/src/server/conversation/codexRuntimeInventory.js +113 -0
  85. package/src/server/conversation/codexRuntimeLifecycle.js +39 -13
  86. package/src/server/conversation/codexRuntimeStart.js +431 -0
  87. package/src/server/conversation/codexRuntimeStop.js +324 -0
  88. package/src/server/conversation/codexSessionCleanup.js +25 -10
  89. package/src/server/conversation/codexSessionReconciliation.js +21 -7
  90. package/src/server/conversation/codexThreadReadiness.js +49 -52
  91. package/src/server/conversation/codexThreadSnapshot.js +210 -0
  92. package/src/server/conversation/codexThreadStatus.js +48 -62
  93. package/src/server/conversation/codexTurn.js +171 -55
  94. package/src/server/conversation/codexTurnActivity.js +210 -0
  95. package/src/server/conversation/codexTurnControl.js +44 -48
  96. package/src/server/conversation/codexTurnEndings.js +610 -0
  97. package/src/server/conversation/codexTurnRecovery.js +97 -848
  98. package/src/server/conversation/codexTurnState.js +138 -111
  99. package/src/server/conversation/completedResponses.js +227 -0
  100. package/src/server/conversation/configuration.js +2 -2
  101. package/src/server/conversation/continuity.js +43 -6
  102. package/src/server/conversation/conversationDelivery.js +394 -0
  103. package/src/server/conversation/conversationEntries.js +365 -0
  104. package/src/server/conversation/conversationEvents.js +192 -0
  105. package/src/server/conversation/conversationGoals.js +187 -0
  106. package/src/server/conversation/conversationHandles.js +237 -0
  107. package/src/server/conversation/conversationReads.js +206 -0
  108. package/src/server/conversation/conversationRefusals.js +58 -0
  109. package/src/server/conversation/conversationReplacement.js +254 -0
  110. package/src/server/conversation/conversationSending.js +273 -0
  111. package/src/server/conversation/conversationStopping.js +121 -0
  112. package/src/server/conversation/conversationTurnDelivery.js +413 -0
  113. package/src/server/conversation/conversationTurns.js +511 -0
  114. package/src/server/conversation/deliveryTiming.js +25 -3
  115. package/src/server/conversation/fileStorage.js +11 -6
  116. package/src/server/conversation/hookBridge.js +14 -3
  117. package/src/server/conversation/index.js +1 -1
  118. package/src/server/conversation/localExecution.js +82 -625
  119. package/src/server/conversation/nativeCommandCallbacks.js +10 -0
  120. package/src/server/conversation/nativeConversationLifecycle.js +379 -0
  121. package/src/server/conversation/nativeHistoryExport.js +27 -18
  122. package/src/server/conversation/normalize.js +9 -0
  123. package/src/server/conversation/openCodeClient.js +295 -366
  124. package/src/server/conversation/openCodeNativeHistory.js +141 -0
  125. package/src/server/conversation/openCodeNativeSession.js +79 -0
  126. package/src/server/conversation/openCodeProcess.js +109 -53
  127. package/src/server/conversation/openCodeProcessOwner.js +565 -0
  128. package/src/server/conversation/openCodeRuntime.js +111 -1814
  129. package/src/server/conversation/openCodeSessionTurns.js +547 -0
  130. package/src/server/conversation/openCodeTemporaryConversations.js +394 -0
  131. package/src/server/conversation/openCodeTools.js +28 -14
  132. package/src/server/conversation/openCodeTurn.js +80 -74
  133. package/src/server/conversation/openCodeTurnMonitor.js +388 -0
  134. package/src/server/conversation/processIdentity.js +582 -0
  135. package/src/server/conversation/provider.js +21 -6
  136. package/src/server/conversation/providers/api.js +268 -107
  137. package/src/server/conversation/providers/claude.js +29 -6
  138. package/src/server/conversation/providers/claudeDriver.js +325 -250
  139. package/src/server/conversation/providers/codex.js +69 -33
  140. package/src/server/conversation/providers/codexDriver.js +503 -1081
  141. package/src/server/conversation/providers/codexDriverConnection.js +331 -0
  142. package/src/server/conversation/providers/codexDriverTurns.js +464 -0
  143. package/src/server/conversation/providers/opencode.js +30 -4
  144. package/src/server/conversation/providers/opencodeDriver.js +321 -659
  145. package/src/server/conversation/providers/opencodeStandalone.js +475 -0
  146. package/src/server/conversation/providers/scoped.js +64 -19
  147. package/src/server/conversation/runtime.js +171 -2863
  148. package/src/server/conversation/storage.js +91 -50
  149. package/src/server/conversation/streams.js +15 -2
  150. package/src/server/conversation/structuredOutput.js +7 -11
  151. package/src/server/conversation/systemPrompt.js +13 -1
  152. package/src/server/conversation/tools.js +47 -15
  153. package/src/server/conversation/transcript.js +49 -20
  154. package/src/server/hosts/claudeTurn.js +2 -4
  155. package/src/server/hosts/codexConfiguration.js +6 -8
  156. package/src/server/hosts/codexEvents.js +4 -2
  157. package/src/server/hosts/codexProcess.js +1 -1
  158. package/src/server/hosts/codexProvider.js +11 -9
  159. package/src/server/hosts/codexTurn.js +3 -3
  160. package/src/server/hosts/openCodeClient.js +4 -2
  161. package/src/server/index.js +0 -2
  162. package/src/server/lib/aiConnectionClient.js +35 -25
  163. package/src/server/lib/ndjson.js +1 -0
  164. package/src/server/lib/providers/anthropicClient.js +15 -6
  165. package/src/server/lib/providers/common.js +10 -6
  166. package/src/server/lib/providers/openAiCompatibleClient.js +40 -23
  167. package/src/server/lib/serviceToolCatalog.js +29 -283
  168. package/src/server/lib/serviceToolEntries.js +270 -0
  169. package/src/server/types.js +78 -0
  170. package/src/shared/assistantResource.js +0 -23
  171. package/src/shared/conversation/answerChoiceSugar.js +2 -14
  172. package/src/shared/conversation/messageInputSugar.js +18 -0
  173. package/src/shared/conversation/numberedQuestionSugar.js +6 -16
  174. package/src/shared/conversation/pagination.js +1 -1
  175. package/src/shared/conversation/questions.js +1 -0
  176. package/src/shared/conversation/streaming.js +1 -0
  177. package/src/shared/index.js +0 -1
  178. package/src/testing/conversationStorageContract.js +20 -0
  179. package/test/aiConnectionClient.test.js +42 -1
  180. package/test/apiConversationDriver.test.js +217 -38
  181. package/test/assistantBoundedToolLoop.test.js +1 -1
  182. package/test/claudeConversationSession.test.js +15 -11
  183. package/test/claudeInstructionResume.test.js +1 -1
  184. package/test/claudeProcess.test.js +9 -9
  185. package/test/claudeTurn.test.js +21 -8
  186. package/test/codexAccountChange.test.js +5 -3
  187. package/test/codexConversationSession.test.js +22 -21
  188. package/test/codexEvents.test.js +3 -1
  189. package/test/codexHelperDeletion.test.js +3 -7
  190. package/test/codexIsolation.test.js +6 -6
  191. package/test/codexModelCatalog.test.js +6 -7
  192. package/test/codexProcess.test.js +3 -3
  193. package/test/codexThreadSelection.test.js +4 -1
  194. package/test/codexTurn.test.js +29 -20
  195. package/test/conversationApiFacilities.test.js +11 -9
  196. package/test/conversationAttachments.test.js +3 -3
  197. package/test/conversationConfiguration.test.js +4 -4
  198. package/test/conversationNativeStorage.test.js +11 -10
  199. package/test/conversationReplacement.test.js +13 -13
  200. package/test/conversationRuntime.test.js +9 -5
  201. package/test/conversationSession.test.js +78 -22
  202. package/test/conversationTools.test.js +47 -26
  203. package/test/fileConversationStorage.test.js +10 -3
  204. package/test/fixtures/codexHelperThreadLedger.js +3 -3
  205. package/test/frameworkActions.test.js +33 -18
  206. package/test/localConversationExecution.test.js +4 -4
  207. package/test/localExecution.test.js +1 -1
  208. package/test/openCodeConnectionChange.test.js +3 -2
  209. package/test/openCodeRuntime.test.js +8 -7
  210. package/test/openCodeTools.test.js +1 -1
  211. package/test/opencodeConversationSession.test.js +4 -3
  212. package/test/serviceToolCatalog.test.js +3 -8
  213. package/src/server/lib/assistantCompletion.js +0 -190
  214. package/src/server/lib/assistantToolLoop.js +0 -259
@@ -1,24 +1,15 @@
1
- import { createHash, randomUUID } from "node:crypto";
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 { claudeMessageBlocks, deleteClaudeConversationHistory, readClaudeHistory, requireClaudeSessionId, retireClaudeConversationHistory } from "./claudeHistory.js";
6
- import { bindClaudeConversationAccount, claudeCodeArguments, createClaudeAccountQueries, createClaudeCodeProcess, stopClaudeCodeProcess } from "./claudeProcess.js";
7
- import { claudeApplicationToolResponse } from "./claudeTools.js";
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
- const ACTIVE_TURN_STATES = ["starting", "active", "finalizing"];
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 = failure,
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
- /** Native renewal receipt, completion and cleanup moved from the session provider. */
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 = failure, unreadableCode = "assistant_claude_turn_unreadable",
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)) throw createError("The successor Claude conversation already contains unrelated messages.");
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) throw createError("Stop the current turn before renewing this conversation.");
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(failure(message, "conversation_invalid_goal"), {
131
- statusCode: 400, delivery: "not-sent", messageCode, messageParams: {}
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({ read, update });
176
+ return Object.freeze({
177
+ read,
178
+ update
179
+ });
177
180
  }
178
181
 
179
- /** The original retained entry and command owner; hosts supply authorized storage and message effects. */
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 = failure,
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 ? text(entry.turn.phase) : "" } : null;
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 && !closing && !closingSessions.has(entry.context.key) &&
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 existing current entry and native receipt owner.
303
+ // This lookup uses only the current entry and its native receipt owner.
233
304
  function readFinalAssistantResult(contextKey, conversationId = "", turnId = "") {
234
- if (!text(contextKey) || !text(conversationId) || !text(turnId) || closing || closingSessions.has(contextKey)) return null;
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 { throw createError(`The saved Claude conversation ${conversationId} is unreadable. Restore its saved conversation state.`); }
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 || text(mainId) ||
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: now(), updatedAt: now()
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) throw createError("The saved Claude goal cannot be both paused and blocked.");
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 || closing || closingSessions.has(entry.context.key) || application?.sessionClosing) {
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 || closing || closingSessions.has(entry.context.key)) {
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 (closing || closingSessions.has(entry.context.key) || application?.sessionClosing) throw createError("This session is closing.");
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 Object.assign(createError("Claude is still working. Wait for it to finish before freeing its context."), { statusCode: 409,
586
- messageCode: "assistant.claude.compactWhileWorking", messageParams: {} });
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) throw createError("This conversation is still working.");
742
- await send(entry, input);
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(failure(
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", statusCode: 503 });
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
- receive, read, stop, interrupt, readFinalAssistantResult, invalidateFinalAssistantResult,
1791
- ...(entry ? { readResult, updateState, stopProcess } : {}),
1792
- ...(instructions ? {
1793
- prepare: instructions.prepare,
1794
- whenIdle: instructions.whenIdle,
1795
- instructionArguments: instructions.instructionArguments,
1796
- invalidateInstructions: instructions.invalidate
1797
- } : {}),
1798
- begin({ id, maxOutputCharacters = 4 * 1024 * 1024 } = {}) {
1799
- if (active) throw failure("This Claude turn is still working.");
1800
- if (!id || !Number.isSafeInteger(maxOutputCharacters) || maxOutputCharacters < 1) throw new TypeError("Invalid Claude turn configuration.");
1801
- generation++;
1802
- inputOwner = null;
1803
- acceptedInput = null;
1804
- clearFinalAssistantResult();
1805
- turnId = id;
1806
- maximumOutput = maxOutputCharacters;
1807
- messages.clear();
1808
- pendingAssistantMessages.clear();
1809
- assistantRoles.clear();
1810
- result = "";
1811
- phase = "";
1812
- messageId = "";
1813
- currentCommandId = "";
1814
- outcome = null;
1815
- cancelling = false;
1816
- stopped = false;
1817
- active = true;
1818
- },
1819
- reset() {
1820
- if (active || inFlight.size || tasks.size) throw failure("Stop Claude before resetting its turn view.");
1821
- messages.clear();
1822
- pendingAssistantMessages.clear();
1823
- assistantRoles.clear();
1824
- result = "";
1825
- outcome = null;
1826
- inputOwner = null;
1827
- acceptedInput = null;
1828
- clearFinalAssistantResult();
1829
- },
1830
- async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000, steer = false } = {}) {
1831
- if (!active || stopped || cancelling) throw Object.assign(
1832
- failure("Claude has no active turn for this message.", !active && !stopped && !cancelling
1833
- ? "conversation_steer_turn_finished" : "assistant_claude_turn_failed"),
1834
- { delivery: "not-sent", messageCode: "assistant.claude.noActiveTurn", messageParams: {},
1835
- ...(!active && !stopped && !cancelling ? { statusCode: 409 } : {}) }
1836
- );
1837
- if (!uuid || typeof accept !== "function" || admissions.has(uuid)) throw new TypeError("A distinct message id and admission writer are required.");
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
  }