@jskit-ai/assistant-core 0.1.200 → 0.1.202

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 (164) hide show
  1. package/examples/conversation/README.md +8 -1
  2. package/examples/conversation/package.json +5 -5
  3. package/fixtures/responsive-assistant/App.vue +4 -1
  4. package/package.json +99 -36
  5. package/src/client/conversation/AssistantConversationElement.vue +27 -4
  6. package/src/client/conversation/AssistantConversationStatus.vue +24 -0
  7. package/src/client/conversation/AssistantGoalControl.vue +8 -5
  8. package/src/client/conversation/AssistantTranscript.vue +10 -3
  9. package/src/client/conversation/index.js +2 -0
  10. package/src/client/conversation/messageDelivery.js +115 -32
  11. package/src/client/conversation/retainedConversation.js +32 -0
  12. package/src/client/conversation/useAssistantQuestions.js +76 -0
  13. package/src/client/lib/assistantApi.js +72 -2
  14. package/src/server/conversation/agentRun.js +222 -0
  15. package/src/server/conversation/attachments.js +93 -0
  16. package/src/server/conversation/claudeClient.js +221 -0
  17. package/src/server/conversation/claudeHistory.js +256 -0
  18. package/src/server/conversation/claudeProcess.js +327 -0
  19. package/src/server/conversation/claudeTools.js +42 -0
  20. package/src/server/conversation/claudeTurn.js +1082 -0
  21. package/src/server/conversation/codexConfiguration.js +1183 -0
  22. package/src/server/conversation/codexConversations.js +1082 -0
  23. package/src/server/conversation/codexDetachedTurn.js +332 -0
  24. package/src/server/conversation/codexEvents.js +194 -1
  25. package/src/server/conversation/codexGoalCommands.js +124 -0
  26. package/src/server/conversation/codexHelperExecution.js +306 -0
  27. package/src/server/conversation/codexHelperThreadLedger.js +804 -0
  28. package/src/server/conversation/codexHelperThreadRecovery.js +395 -0
  29. package/src/server/conversation/codexHelperThreads.js +638 -0
  30. package/src/server/conversation/codexHistoryAdapter.js +369 -0
  31. package/src/server/conversation/codexMessageCommands.js +663 -0
  32. package/src/server/conversation/codexMessageDelivery.js +211 -0
  33. package/src/server/conversation/codexNativeHistoryExport.js +95 -0
  34. package/src/server/conversation/codexNotificationObserver.js +482 -0
  35. package/src/server/conversation/codexNotificationQueue.js +103 -0
  36. package/src/server/conversation/codexObservationControl.js +174 -0
  37. package/src/server/conversation/codexOutput.js +931 -0
  38. package/src/server/conversation/codexProcess.js +1559 -0
  39. package/src/server/conversation/codexProcessLeader.js +14 -0
  40. package/src/server/conversation/codexProvider.js +2710 -0
  41. package/src/server/conversation/codexProviderOwner.js +1058 -0
  42. package/src/server/conversation/codexProviderSelection.js +218 -0
  43. package/src/server/conversation/codexReasoning.js +314 -0
  44. package/src/server/conversation/codexRenewalCommands.js +290 -0
  45. package/src/server/conversation/codexResultSettlement.js +409 -0
  46. package/src/server/conversation/codexRunJournal.js +658 -0
  47. package/src/server/conversation/codexRuntimeLifecycle.js +288 -0
  48. package/src/server/conversation/codexSessionCleanup.js +96 -0
  49. package/src/server/conversation/codexSessionReconciliation.js +284 -0
  50. package/src/server/conversation/codexThreadReadiness.js +397 -0
  51. package/src/server/conversation/codexThreadStatus.js +354 -0
  52. package/src/server/conversation/codexTurn.js +701 -255
  53. package/src/server/conversation/codexTurnControl.js +317 -0
  54. package/src/server/conversation/codexTurnRecovery.js +990 -0
  55. package/src/server/conversation/codexTurnState.js +549 -0
  56. package/src/server/conversation/commandHook.js +18 -0
  57. package/src/server/conversation/commandWrapper.js +37 -0
  58. package/src/server/conversation/configuration.js +43 -0
  59. package/src/server/conversation/continuity.js +289 -0
  60. package/src/server/conversation/fileStorage.js +55 -0
  61. package/src/server/conversation/hookBridge.js +19 -0
  62. package/src/server/conversation/index.js +13 -0
  63. package/src/server/conversation/localExecution.js +665 -0
  64. package/src/server/conversation/memoryStorage.js +7 -72
  65. package/src/server/conversation/nativeHistoryExport.js +90 -0
  66. package/src/server/conversation/nativeStorage.js +23 -0
  67. package/src/server/conversation/openCodeClient.js +209 -19
  68. package/src/server/conversation/openCodePlugin.js +84 -0
  69. package/src/server/conversation/openCodeProcess.js +476 -0
  70. package/src/server/conversation/openCodeRuntime.js +1578 -0
  71. package/src/server/conversation/openCodeTools.js +60 -0
  72. package/src/server/conversation/openCodeTurn.js +557 -0
  73. package/src/server/conversation/provider.js +30 -0
  74. package/src/server/conversation/providers/api.js +136 -0
  75. package/src/server/conversation/providers/claude.js +63 -0
  76. package/src/server/conversation/providers/claudeDriver.js +425 -0
  77. package/src/server/conversation/providers/codex.js +248 -0
  78. package/src/server/conversation/providers/codexDriver.js +1078 -0
  79. package/src/server/conversation/providers/opencode.js +49 -0
  80. package/src/server/conversation/providers/opencodeDriver.js +650 -0
  81. package/src/server/conversation/providers/scoped.js +312 -0
  82. package/src/server/conversation/runtime.js +2220 -0
  83. package/src/server/conversation/runtimeStateUpgrade.js +125 -0
  84. package/src/server/conversation/storage.js +184 -0
  85. package/src/server/conversation/streams.js +7 -2
  86. package/src/server/conversation/structuredOutput.js +240 -0
  87. package/src/server/conversation/systemPrompt.js +54 -0
  88. package/src/server/conversation/tools.js +84 -0
  89. package/src/server/conversation/transcript.js +5 -4
  90. package/src/server/hosts/claudeHistory.js +5 -0
  91. package/src/server/hosts/claudeProcess.js +11 -0
  92. package/src/server/hosts/claudeTurn.js +4 -0
  93. package/src/server/hosts/codexConfiguration.js +20 -0
  94. package/src/server/hosts/codexEvents.js +13 -0
  95. package/src/server/hosts/codexProcess.js +12 -0
  96. package/src/server/hosts/codexProvider.js +23 -0
  97. package/src/server/hosts/codexTurn.js +20 -0
  98. package/src/server/hosts/nativeHistory.js +3 -0
  99. package/src/server/hosts/openCodeClient.js +3 -0
  100. package/src/server/hosts/openCodeProcess.js +23 -0
  101. package/src/server/hosts/openCodeTurn.js +7 -0
  102. package/src/server/index.js +2 -0
  103. package/src/server/lib/aiConnectionClient.js +13 -6
  104. package/src/server/lib/assistantCompletion.js +190 -0
  105. package/src/server/lib/assistantToolLoop.js +314 -0
  106. package/src/server/lib/serviceToolCatalog.js +25 -10
  107. package/src/shared/assistantResponseText.js +29 -0
  108. package/src/shared/conversation/attachments.js +19 -0
  109. package/src/shared/conversation/index.js +3 -0
  110. package/src/shared/conversation/pagination.js +78 -0
  111. package/src/shared/conversation/patches.js +150 -0
  112. package/src/shared/conversation/questions.js +48 -0
  113. package/src/shared/conversation/streaming.js +7 -4
  114. package/src/shared/index.js +2 -0
  115. package/src/shared/nativeProviders.js +45 -0
  116. package/src/testing/conversationStorageContract.js +31 -1
  117. package/src/testing/nativeCodex.js +11 -0
  118. package/src/testing/nativeOpenCode.js +3 -0
  119. package/test/aiConnectionClient.test.js +2 -0
  120. package/test/apiConversationDriver.test.js +73 -0
  121. package/test/assistantApiSurfaceHeader.test.js +85 -0
  122. package/test/assistantBoundedToolLoop.test.js +285 -0
  123. package/test/assistantScroll.browser.test.js +16 -0
  124. package/test/claudeClient.test.js +189 -0
  125. package/test/claudeConversationSession.test.js +1173 -0
  126. package/test/claudeInstructionResume.test.js +82 -0
  127. package/test/claudeProcess.test.js +204 -0
  128. package/test/claudeTurn.test.js +253 -0
  129. package/test/codexConversationSession.test.js +2122 -0
  130. package/test/codexGoal.test.js +37 -0
  131. package/test/codexHelperDeletion.test.js +163 -0
  132. package/test/codexHelperThreadLedger.test.js +494 -0
  133. package/test/codexHistoryAdapter.test.js +541 -0
  134. package/test/codexInteractiveArguments.test.js +30 -0
  135. package/test/codexIsolation.test.js +756 -0
  136. package/test/codexModelCatalog.test.js +362 -0
  137. package/test/codexNativeHistoryExport.test.js +81 -0
  138. package/test/codexThreadSelection.test.js +136 -0
  139. package/test/codexTurn.test.js +2158 -0
  140. package/test/conversation.test.js +110 -0
  141. package/test/conversationAgentRun.test.js +326 -0
  142. package/test/conversationApiFacilities.test.js +90 -0
  143. package/test/conversationAttachments.test.js +238 -0
  144. package/test/conversationCommandWrapper.test.js +48 -0
  145. package/test/conversationConfiguration.test.js +174 -0
  146. package/test/conversationNativeStorage.test.js +25 -0
  147. package/test/conversationReplacement.test.js +172 -0
  148. package/test/conversationRuntime.test.js +241 -0
  149. package/test/conversationRuntimeStateUpgrade.test.js +144 -0
  150. package/test/conversationSession.test.js +1080 -0
  151. package/test/conversationStreaming.test.js +68 -0
  152. package/test/conversationSystemPrompt.test.js +84 -0
  153. package/test/conversationTools.test.js +416 -0
  154. package/test/fileConversationStorage.test.js +73 -0
  155. package/test/fixtures/codexHelperThreadLedger.js +39 -0
  156. package/test/localConversationExecution.test.js +90 -0
  157. package/test/messageDelivery.test.js +234 -1
  158. package/test/nativeConversationRetirement.test.js +215 -0
  159. package/test/openCodeClient.test.js +515 -0
  160. package/test/openCodeProcess.test.js +177 -0
  161. package/test/openCodeRuntime.test.js +322 -0
  162. package/test/openCodeTools.test.js +287 -0
  163. package/test/opencodeConversationSession.test.js +789 -0
  164. package/test/serviceToolCatalog.test.js +24 -0
@@ -0,0 +1,93 @@
1
+ import path from "node:path";
2
+ import { conversationAttachmentManifest } from "../../shared/conversation/attachments.js";
3
+
4
+ function invalid(message) {
5
+ return Object.assign(new Error(message), { code: "conversation_invalid_attachments", statusCode: 400 });
6
+ }
7
+
8
+ export function conversationAttachmentIds(value = []) {
9
+ if (!Array.isArray(value) || value.length > 10 || value.some(id => typeof id !== "string" || !id.trim() || id.length > 256) ||
10
+ new Set(value).size !== value.length) {
11
+ throw invalid("Attachments require at most ten distinct, nonempty attachment IDs.");
12
+ }
13
+ return [...value];
14
+ }
15
+
16
+ /** Resolve app-owned files for one dispatch. Only receipts enter the transcript. */
17
+ export function createConversationAttachmentReader({ attachments, context, conversationId, signal, authorize, types, maximumBytes }) {
18
+ let bytes = 0;
19
+ return async references => {
20
+ if (!references?.length) return { attachments: [], content: [], localFiles: [], attachmentManifest: "" };
21
+ if (typeof attachments?.resolve !== "function") {
22
+ throw Object.assign(new Error("File attachments are not configured for this conversation."), { code: "conversation_unsupported", statusCode: 400 });
23
+ }
24
+ const receipts = [];
25
+ const content = [];
26
+ const localFiles = [];
27
+ // Continuity may retain files from several earlier messages. Each resolver
28
+ // call uses the same bounded batch as a new user message.
29
+ for (let offset = 0; offset < references.length; offset += 10) {
30
+ const attachmentIds = conversationAttachmentIds(references.slice(offset, offset + 10).map(file => file.attachmentId));
31
+ signal.throwIfAborted();
32
+ await authorize();
33
+ const resolved = await attachments.resolve({ attachmentIds, context, conversationId, signal });
34
+ signal.throwIfAborted();
35
+ const local = resolved?.localFiles !== undefined;
36
+ if (!Array.isArray(resolved?.attachments) || resolved.attachments.length !== attachmentIds.length ||
37
+ (local ? !Array.isArray(resolved.localFiles) || resolved.localFiles.length !== attachmentIds.length ||
38
+ resolved.content !== undefined && (!Array.isArray(resolved.content) || resolved.content.length)
39
+ : !Array.isArray(resolved.content) || !resolved.content.length || resolved.content.length > 100)) {
40
+ throw invalid("The attachment resolver must return a receipt for every requested file and bounded model content.");
41
+ }
42
+ if (local && !types.includes("localFile")) {
43
+ throw invalid("This engine cannot accept local attachment files. Supply extracted text or supported image bytes.");
44
+ }
45
+ for (const [index, receipt] of resolved.attachments.entries()) {
46
+ if (receipt?.attachmentId !== attachmentIds[index] || typeof receipt.fileName !== "string" || !receipt.fileName.trim() ||
47
+ receipt.fileName.length > 256 || !Number.isSafeInteger(receipt.size) || receipt.size < 0 ||
48
+ receipt.reference !== undefined && (typeof receipt.reference !== "string" || receipt.reference.length > 256)) {
49
+ throw invalid("Attachment receipts require the requested ID, a bounded fileName and a nonnegative size.");
50
+ }
51
+ receipts.push({ attachmentId: receipt.attachmentId, fileName: receipt.fileName, size: receipt.size,
52
+ ...(receipt.reference ? { reference: receipt.reference } : {}) });
53
+ if (local) {
54
+ const file = resolved.localFiles[index];
55
+ if (file?.attachmentId !== receipt.attachmentId || file.fileName !== receipt.fileName || file.size !== receipt.size ||
56
+ typeof receipt.reference !== "string" || !receipt.reference.trim() || file.reference !== receipt.reference ||
57
+ typeof file.path !== "string" || !path.isAbsolute(file.path) || file.path.includes("\0") ||
58
+ typeof file.contentType !== "string" || !/^[\w.+-]+\/[\w.+-]+$/u.test(file.contentType)) {
59
+ throw invalid("Local attachment files require their exact requested receipt and an authorized absolute native path.");
60
+ }
61
+ localFiles.push({ attachmentId: file.attachmentId, fileName: file.fileName, size: file.size,
62
+ reference: file.reference, contentType: file.contentType, path: file.path });
63
+ }
64
+ }
65
+ for (const part of resolved.content || []) {
66
+ if (!types.includes(part?.type)) throw invalid("This engine cannot accept that attachment format. Supply extracted text or a supported image.");
67
+ if (part.type === "text") {
68
+ if (typeof part.text !== "string" || !part.text.trim()) throw invalid("Attachment text must be nonempty.");
69
+ bytes += Buffer.byteLength(part.text);
70
+ content.push({ type: "text", text: part.text });
71
+ } else {
72
+ const data = part.type === "image" ? part.image : part.data;
73
+ if (!(data instanceof Uint8Array) || !data.byteLength || typeof part.mediaType !== "string" ||
74
+ !/^[\w.+-]+\/[\w.+-]+$/u.test(part.mediaType) ||
75
+ part.type === "image" && !["image/png", "image/jpeg", "image/webp", "image/gif"].includes(part.mediaType) ||
76
+ part.filename !== undefined && (typeof part.filename !== "string" || part.filename.length > 256)) {
77
+ throw invalid("Attachments require authorized bytes and a supported media type; remote URLs and paths are not accepted.");
78
+ }
79
+ bytes += data.byteLength;
80
+ if (bytes > maximumBytes) throw invalid("Attachment content exceeds the configured byte limit for this request.");
81
+ content.push(part.type === "image" ? { type: "image", image: Buffer.from(data), mediaType: part.mediaType }
82
+ : { type: "file", data: Buffer.from(data), mediaType: part.mediaType, ...(part.filename ? { filename: part.filename } : {}) });
83
+ }
84
+ if (bytes > maximumBytes) throw invalid("Attachment content exceeds the configured byte limit for this request.");
85
+ }
86
+ }
87
+ const attachmentManifest = conversationAttachmentManifest(localFiles);
88
+ bytes += Buffer.byteLength(attachmentManifest);
89
+ if (bytes > maximumBytes) throw invalid("Attachment content exceeds the configured byte limit for this request.");
90
+ await authorize();
91
+ return { attachments: receipts, content, localFiles, attachmentManifest };
92
+ };
93
+ }
@@ -0,0 +1,221 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { TextDecoder } from "node:util";
3
+
4
+ const CLAUDE_JSON_MAX_FRAME_BYTES = 16 * 1024 * 1024;
5
+
6
+ function claudeProtocolError(message, code = "assistant_claude_protocol_error") {
7
+ return Object.assign(new Error(message), { code });
8
+ }
9
+
10
+ // Geometric growth keeps fragmented frames linear with bounded memory. Decode complete
11
+ // frames, so a UTF-8 character split across pipe reads is never corrupted.
12
+ async function* readClaudeJsonFrames(stream, { maxFrameBytes = CLAUDE_JSON_MAX_FRAME_BYTES, allowIncompleteTail = false } = {}) {
13
+ if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes < 1) {
14
+ throw new TypeError("Claude JSON frame limits must be positive integers.");
15
+ }
16
+ const decoder = new TextDecoder("utf-8", { fatal: true });
17
+ let buffer = Buffer.alloc(0);
18
+ let length = 0;
19
+ function append(part) {
20
+ const required = length + part.length;
21
+ if (required > maxFrameBytes) throw claudeProtocolError("Claude JSON frame exceeded its size limit.");
22
+ if (required > buffer.length) {
23
+ const next = Buffer.allocUnsafe(Math.min(maxFrameBytes, Math.max(required, buffer.length * 2, 4096)));
24
+ buffer.copy(next, 0, 0, length);
25
+ buffer = next;
26
+ }
27
+ part.copy(buffer, length);
28
+ length = required;
29
+ }
30
+ function parse(bytes) {
31
+ let frame;
32
+ try {
33
+ const line = decoder.decode(bytes).trim();
34
+ if (!line) return null;
35
+ frame = JSON.parse(line);
36
+ } catch {
37
+ // Never include the frame: it can contain prompts, tool results, or secrets.
38
+ throw claudeProtocolError("Claude returned invalid UTF-8 or JSON.");
39
+ }
40
+ if (!frame || typeof frame !== "object" || Array.isArray(frame) || typeof frame.type !== "string") {
41
+ throw claudeProtocolError("Claude returned a JSON frame without an event type.");
42
+ }
43
+ return frame;
44
+ }
45
+ for await (const input of stream) {
46
+ const chunk = Buffer.isBuffer(input) ? input : Buffer.from(input);
47
+ let offset = 0;
48
+ for (let end = chunk.indexOf(10, offset); end !== -1; end = chunk.indexOf(10, offset)) {
49
+ const part = chunk.subarray(offset, end);
50
+ if (length + part.length > maxFrameBytes) throw claudeProtocolError("Claude JSON frame exceeded its size limit.");
51
+ if (length) append(part);
52
+ const bytes = length ? buffer.subarray(0, length) : part;
53
+ length = 0;
54
+ const frame = parse(bytes);
55
+ if (frame) yield frame;
56
+ offset = end + 1;
57
+ }
58
+ append(chunk.subarray(offset));
59
+ }
60
+ // A truncated write is not a complete protocol frame, even if it parses.
61
+ if (length && !allowIncompleteTail) throw claudeProtocolError("Claude JSON stream ended in an incomplete frame.");
62
+ }
63
+
64
+ function createClaudeJsonClient({ stream, onEvent = async () => {}, onControlRequest, onFailure = async () => {}, timeoutMs = 30_000 } = {}) {
65
+ const requests = new Map();
66
+ const incoming = new Map();
67
+ let failure = null;
68
+ let closed = false;
69
+ let writes = Promise.resolve();
70
+ let events = Promise.resolve();
71
+ let queuedEventBytes = 0;
72
+ let queuedControlBytes = 0;
73
+
74
+ function fail(error) {
75
+ failure ||= error;
76
+ for (const pending of requests.values()) pending.reject(failure);
77
+ requests.clear();
78
+ for (const pending of incoming.values()) pending.controller.abort(failure);
79
+ stream.destroy();
80
+ }
81
+
82
+ // Duplex teardown can emit an error after the readable iterator has ended.
83
+ // Keep its owner attached until both sides have actually closed.
84
+ stream.on("error", fail);
85
+ stream.once("close", () => stream.off("error", fail));
86
+
87
+ function write(frame) {
88
+ const payload = `${JSON.stringify(frame)}\n`;
89
+ if (Buffer.byteLength(payload) > CLAUDE_JSON_MAX_FRAME_BYTES) {
90
+ return Promise.reject(claudeProtocolError("Claude input exceeded its size limit."));
91
+ }
92
+ const next = writes.then(() => new Promise((resolve, reject) => {
93
+ if (failure || closed || stream.destroyed) {
94
+ reject(failure || claudeProtocolError("Claude connection is closed."));
95
+ return;
96
+ }
97
+ // A write callback, unlike write()'s boolean return, respects backpressure
98
+ // and reports EPIPE. A completed write is NOT an admission acknowledgement.
99
+ stream.write(payload, (error) => error ? reject(error) : resolve());
100
+ }));
101
+ writes = next.catch((error) => { fail(error); });
102
+ return next;
103
+ }
104
+
105
+ async function request(request, options = {}) {
106
+ if (failure || closed) throw failure || claudeProtocolError("Claude connection is closed.");
107
+ const requestId = randomUUID();
108
+ let timer;
109
+ const response = new Promise((resolve, reject) => {
110
+ timer = setTimeout(() => {
111
+ requests.delete(requestId);
112
+ reject(claudeProtocolError(`Claude ${request.subtype} timed out.`, "assistant_claude_control_timeout"));
113
+ }, options.timeoutMs ?? timeoutMs);
114
+ requests.set(requestId, { resolve, reject });
115
+ });
116
+ // Install both observers before a write can fail or an immediate reply arrives.
117
+ try {
118
+ const [, result] = await Promise.all([
119
+ write({ type: "control_request", request_id: requestId, request }), response
120
+ ]);
121
+ return result;
122
+ } finally {
123
+ clearTimeout(timer);
124
+ requests.delete(requestId);
125
+ }
126
+ }
127
+
128
+ function receiveControl(frame) {
129
+ const id = frame.request_id;
130
+ const bytes = Buffer.byteLength(JSON.stringify(frame));
131
+ if (typeof id !== "string" || !id || id.length > 256 || incoming.has(id) || incoming.size >= 32 ||
132
+ queuedControlBytes + bytes > 2 * CLAUDE_JSON_MAX_FRAME_BYTES) {
133
+ throw claudeProtocolError("Claude control processing exceeded its limits or reused an active request id.");
134
+ }
135
+ const controller = new AbortController();
136
+ const precedingEvents = events;
137
+ queuedControlBytes += bytes;
138
+ const pending = { controller };
139
+ incoming.set(id, pending);
140
+ pending.done = (async () => {
141
+ let response;
142
+ try {
143
+ // Admission and preceding native tool-use events must be persisted first.
144
+ // Waiting here does not block the reader from answering interrupt controls.
145
+ await precedingEvents;
146
+ controller.signal.throwIfAborted();
147
+ if (!onControlRequest) throw claudeProtocolError("This control request requires the native Claude Code terminal.");
148
+ const value = await onControlRequest(frame.request, { signal: controller.signal });
149
+ response = { subtype: "success", request_id: id, response: value || {} };
150
+ } catch (error) {
151
+ response = { subtype: "error", request_id: id, error: error.message || "Claude control request failed." };
152
+ }
153
+ if (!controller.signal.aborted && !closed && !failure) await write({ type: "control_response", response });
154
+ })().catch(fail).finally(() => { incoming.delete(id); queuedControlBytes -= bytes; });
155
+ }
156
+
157
+ const completion = (async () => {
158
+ try {
159
+ for await (const frame of readClaudeJsonFrames(stream)) {
160
+ if (frame.type === "control_response") {
161
+ const response = frame.response;
162
+ const pending = requests.get(response?.request_id);
163
+ if (!pending) continue; // A timed-out request may still get a reply.
164
+ requests.delete(response.request_id);
165
+ if (response.subtype === "success") pending.resolve(response.response || {});
166
+ else pending.reject(claudeProtocolError(String(response.error || "Claude control request failed.")));
167
+ } else if (frame.type === "control_request") {
168
+ receiveControl(frame);
169
+ } else if (frame.type === "control_cancel_request") {
170
+ incoming.get(frame.request_id)?.controller.abort(claudeProtocolError("Claude cancelled this control request."));
171
+ } else {
172
+ const bytes = Buffer.byteLength(JSON.stringify(frame));
173
+ queuedEventBytes += bytes;
174
+ if (queuedEventBytes > 2 * CLAUDE_JSON_MAX_FRAME_BYTES) {
175
+ throw claudeProtocolError("Claude event processing exceeded its queue limit.");
176
+ }
177
+ // Persist events in order without blocking control replies behind
178
+ // disk writes or realtime subscribers. Bound the queued payload too.
179
+ events = events.then(async () => {
180
+ try {
181
+ if (!closed && !failure) await onEvent(frame);
182
+ } finally {
183
+ queuedEventBytes -= bytes;
184
+ }
185
+ });
186
+ void events.catch(fail);
187
+ }
188
+ }
189
+ await events;
190
+ if (!closed) throw claudeProtocolError("Claude event connection ended unexpectedly.", "assistant_claude_observation_lost");
191
+ } catch (error) {
192
+ if (!closed) {
193
+ fail(error);
194
+ await onFailure(failure);
195
+ }
196
+ } finally {
197
+ fail(failure || claudeProtocolError("Claude connection is closed."));
198
+ }
199
+ })();
200
+ // Callers can await completion; the background observer must never leak an
201
+ // unhandled rejection if their failure callback itself fails.
202
+ void completion.catch(() => {});
203
+
204
+ return Object.freeze({
205
+ completion,
206
+ initialize: ({ hooks } = {}) => request({ subtype: "initialize", ...(hooks ? { hooks } : {}) }),
207
+ interrupt: () => request({ subtype: "interrupt" }),
208
+ request,
209
+ whenRequestsIdle: () => Promise.all([...incoming.values()].map(pending => pending.done)),
210
+ send(message, { messageId = randomUUID(), sessionId = "" } = {}) {
211
+ return write({ type: "user", uuid: messageId, session_id: sessionId,
212
+ parent_tool_use_id: null, message: { role: "user", content: message } });
213
+ },
214
+ close() {
215
+ closed = true;
216
+ fail(claudeProtocolError("Claude connection was closed."));
217
+ }
218
+ });
219
+ }
220
+
221
+ export { CLAUDE_JSON_MAX_FRAME_BYTES, createClaudeJsonClient, readClaudeJsonFrames };
@@ -0,0 +1,256 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { lstat, readdir, realpath, rm, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { readClaudeJsonFrames } from "./claudeClient.js";
5
+ import { createNativeHistoryExport, retireNativeConversation } from "./nativeHistoryExport.js";
6
+
7
+ const UUID_PATTERN = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/iu;
8
+
9
+ function requireClaudeSessionId(id) {
10
+ if (!UUID_PATTERN.test(id)) throw new TypeError("Invalid Claude conversation id.");
11
+ return id;
12
+ }
13
+
14
+ async function archivedClaudeProjectDirectory(workdir) {
15
+ const canonical = await realpath(workdir).catch((error) => {
16
+ if (error.code === "ENOENT") return workdir;
17
+ throw error;
18
+ });
19
+ return canonical.normalize("NFC").replace(/[^a-zA-Z0-9]/gu, "-");
20
+ }
21
+
22
+ function matchingClaudeProjectDirectories(names, directory) {
23
+ return names.filter((name) => directory.length <= 200
24
+ ? name === directory
25
+ : name.startsWith(`${directory.slice(0, 200)}-`));
26
+ }
27
+
28
+ export async function listClaudeConversationStorage({ configRoot, binding }) {
29
+ if (!path.isAbsolute(configRoot) || !path.isAbsolute(binding.workdir || "")) throw new TypeError("Claude inventory requires absolute directories.");
30
+ const directory = await archivedClaudeProjectDirectory(binding.workdir);
31
+ const projects = path.join(configRoot, "projects");
32
+ const names = await readdir(projects).catch((error) => { if (error.code === "ENOENT") return []; throw error; });
33
+ const candidates = matchingClaudeProjectDirectories(names, directory);
34
+ if (names.length > 10000 || candidates.length > 1) throw new Error("Claude project inventory is ambiguous or exceeds its limit.");
35
+ const ids = new Set();
36
+ for (const name of candidates) {
37
+ const entries = await readdir(path.join(projects, name), { withFileTypes: true });
38
+ if (entries.length > 10000) throw new Error("Claude conversation inventory exceeds its limit.");
39
+ for (const entry of entries) {
40
+ const id = entry.name.replace(/\.jsonl(?:\.(?:superseded|orphaned)-.*)?$/u, "");
41
+ if (UUID_PATTERN.test(id) && entry.isFile()) ids.add(id);
42
+ }
43
+ }
44
+ return [...ids].sort().map((conversationId) => ({ ...binding, conversationId }));
45
+ }
46
+
47
+ // Claude's SDK documents this layout. Only the requested native conversation is
48
+ // read; authentication files are never opened. Long paths use the CLI's prefix
49
+ // lookup because its native and Node builds use different hash suffixes.
50
+ async function claudeHistoryPath({ configRoot, workdir, conversationId }) {
51
+ requireClaudeSessionId(conversationId);
52
+ const canonical = (await realpath(workdir)).normalize("NFC");
53
+ const directory = canonical.replace(/[^a-zA-Z0-9]/gu, "-");
54
+ const projects = path.join(configRoot, "projects");
55
+ const candidates = directory.length <= 200 ? [directory] :
56
+ (await readdir(projects).catch((error) => {
57
+ if (error.code === "ENOENT") return [];
58
+ throw error;
59
+ })).filter((name) => name.startsWith(`${directory.slice(0, 200)}-`));
60
+ for (const name of candidates) {
61
+ const candidate = path.join(projects, name, `${conversationId}.jsonl`);
62
+ const info = await stat(candidate).catch((error) => {
63
+ if (error.code === "ENOENT") return null;
64
+ throw error;
65
+ });
66
+ if (info?.isFile()) return { path: candidate, size: info.size };
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function claudeMessageBlocks(frame, { includeNested = false } = {}) {
72
+ if (frame.type !== "assistant" || frame.parent_tool_use_id && !includeNested) return [];
73
+ const content = frame.message?.content;
74
+ if (!Array.isArray(content)) return [];
75
+ // Native frames split one API message into blocks with distinct UUIDs.
76
+ const messageId = frame.uuid || frame.message.id;
77
+ if (!messageId) return [];
78
+ const commentary = content.some((block) => block.type === "tool_use");
79
+ return content.flatMap((block, index) => {
80
+ const text = block.type === "thinking" ? block.thinking : block.type === "text" ? block.text : "";
81
+ return typeof text === "string" && text.trim() ? [{
82
+ id: `claude_${messageId}_${index}`, text, complete: true,
83
+ role: block.type === "thinking" ? "thinking" : commentary ? "commentary" : "assistant"
84
+ }] : [];
85
+ });
86
+ }
87
+
88
+ async function readClaudeHistory(options) {
89
+ const file = await claudeHistoryPath(options);
90
+ if (!file || file.size === 0) return { exists: false, messages: [], userIds: [], text: "" };
91
+ if (file.size > 256 * 1024 * 1024) throw new Error("Claude history exceeds the supported read limit.");
92
+ const messages = new Map();
93
+ const userIds = [];
94
+ let lastUserId = "";
95
+ let goal = null;
96
+ const frames = [];
97
+ const byId = new Map();
98
+ let leafUuid = "";
99
+ let rewound = false;
100
+ // Snapshot the file length. A writer can be in the middle of its last frame;
101
+ // only this native-history read allows that unfinished tail.
102
+ for await (const frame of readClaudeJsonFrames(createReadStream(file.path, { end: file.size - 1 }), {
103
+ allowIncompleteTail: true
104
+ })) {
105
+ if (frame.isSidechain) continue;
106
+ frames.push(frame);
107
+ if (frame.uuid) byId.set(frame.uuid, frame);
108
+ if (["user", "assistant"].includes(frame.type) && frame.uuid) leafUuid = frame.uuid;
109
+ if (frame.type === "last-prompt" && frame.leafUuid) {
110
+ leafUuid = frame.leafUuid;
111
+ rewound ||= frame.rewound === true;
112
+ }
113
+ }
114
+ // Native rewind appends a resume anchor; it retains the discarded branch.
115
+ // Follow the selected branch so restart/admission reads cannot revive it.
116
+ const retained = new Set();
117
+ if (rewound) {
118
+ while (leafUuid && !retained.has(leafUuid)) {
119
+ retained.add(leafUuid);
120
+ leafUuid = byId.get(leafUuid)?.parentUuid;
121
+ }
122
+ }
123
+ for (const frame of frames) {
124
+ if (rewound && frame.uuid && !retained.has(frame.uuid)) continue;
125
+ const status = frame.attachment;
126
+ if (status?.type === "goal_status" && typeof status.condition === "string") {
127
+ if (status.met === true && status.sentinel === true) goal = null;
128
+ else if (status.met === false || status.met === true) {
129
+ const timestamp = Date.parse(frame.timestamp) / 1000;
130
+ goal = {
131
+ threadId: options.conversationId, objective: status.condition,
132
+ createdAt: goal?.objective === status.condition && !status.sentinel ? goal.createdAt : timestamp,
133
+ status: status.met ? "complete" : "active", updatedAt: timestamp,
134
+ reason: typeof status.reason === "string" ? status.reason : ""
135
+ };
136
+ }
137
+ }
138
+ if (frame.type === "user" && !frame.isMeta && frame.uuid &&
139
+ (typeof frame.message?.content === "string" || frame.message?.content?.some((block) => block.type === "text"))) {
140
+ userIds.push(frame.uuid);
141
+ lastUserId = frame.uuid;
142
+ }
143
+ for (const message of claudeMessageBlocks(frame)) {
144
+ messages.set(message.id, { ...message, userId: lastUserId });
145
+ }
146
+ }
147
+ return { exists: true, messages: [...messages.values()], userIds, goal,
148
+ text: [...messages.values()].filter((message) => message.role === "assistant" && message.userId === lastUserId)
149
+ .map((message) => message.text).join("\n") };
150
+ }
151
+
152
+ /** Remove the stopped temporary conversation's active native transcript. */
153
+ export async function deleteClaudeConversationHistory({ configRoot, workdir, conversationId }) {
154
+ const file = await claudeHistoryPath({ configRoot, workdir, conversationId });
155
+ if (file) await rm(file.path, { force: true });
156
+ }
157
+
158
+ export { claudeMessageBlocks, readClaudeHistory, requireClaudeSessionId, archivedClaudeProjectDirectory, matchingClaudeProjectDirectories };
159
+
160
+ const TRANSCRIPT_PATH_PATTERN = /\.jsonl(?:\.(?:superseded|orphaned)-.*)?$/u;
161
+
162
+ export async function retireClaudeConversationHistory({ configRoot, binding, beforeDelete, requireIdle, signal }) {
163
+ const id = requireClaudeSessionId(binding.conversationId);
164
+ if (!path.isAbsolute(configRoot)) throw new TypeError("Claude retirement requires an absolute configuration root.");
165
+ const root = path.resolve(configRoot);
166
+ const projects = path.join(root, "projects");
167
+ const isTranscript = (file) => !file.directory &&
168
+ file.path.startsWith(`${projects}${path.sep}`) && TRANSCRIPT_PATH_PATTERN.test(file.path);
169
+ const missing = (error) => { if (error.code === "ENOENT") return null; throw error; };
170
+ const inspect = async () => {
171
+ await requireIdle();
172
+ const rootInfo = await lstat(root).catch(missing);
173
+ if (!rootInfo) return [];
174
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink() || await realpath(root) !== root) {
175
+ throw new Error("Claude storage root must be a canonical directory without symlink parents.");
176
+ }
177
+ const directory = await archivedClaudeProjectDirectory(binding.workdir);
178
+ const directories = async (parent) => {
179
+ const info = await lstat(parent).catch(missing);
180
+ if (!info) return [];
181
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Claude storage contains an unsafe directory.");
182
+ const names = await readdir(parent);
183
+ if (names.length > 10000) throw new Error("Claude storage directory exceeds its inspection limit.");
184
+ return names;
185
+ };
186
+ const projectNames = await directories(projects);
187
+ const candidates = matchingClaudeProjectDirectories(projectNames, directory);
188
+ const targets = [];
189
+ let matches = 0;
190
+ for (const name of candidates) {
191
+ const parent = path.join(projects, name);
192
+ const names = await directories(parent);
193
+ const owned = names.filter((name) => name === id || name === `${id}.jsonl` ||
194
+ name.startsWith(`${id}.jsonl.superseded-`) || name.startsWith(`${id}.jsonl.orphaned-`));
195
+ if (owned.length) matches += 1;
196
+ targets.push(...owned.map((name) => path.join(parent, name)));
197
+ }
198
+ if (matches > 1) throw new Error("Claude conversation occurs in more than one native project directory.");
199
+ for (const kind of ["file-history", "image-cache", "uploads"]) {
200
+ const parent = path.join(root, kind);
201
+ if ((await directories(parent)).includes(id)) targets.push(path.join(parent, id));
202
+ }
203
+ const files = [];
204
+ const visit = async (file) => {
205
+ const info = await lstat(file);
206
+ if ((!info.isFile() && !info.isDirectory()) || info.isSymbolicLink() || files.length >= 10000) {
207
+ throw new Error("Claude conversation contains unsafe paths or exceeds its inspection limit.");
208
+ }
209
+ files.push({ path: file, directory: info.isDirectory(), inode: info.ino, size: info.size, modified: info.mtimeMs });
210
+ if (info.isDirectory()) for (const name of (await directories(file)).sort()) await visit(path.join(file, name));
211
+ };
212
+ for (const target of targets.sort()) await visit(target);
213
+ const transcripts = files.filter(isTranscript);
214
+ return targets.length ? [{ conversationId: id, workdir: binding.workdir, paths: targets, files,
215
+ ...(transcripts.length ? { updatedAt: new Date(Math.max(...transcripts.map((file) => file.modified))).toISOString() } : {}) }] : [];
216
+ };
217
+ return retireNativeConversation({ binding, inspect, beforeDelete,
218
+ exportConversation: async (_id, onRecord) => {
219
+ const deadline = AbortSignal.timeout(300_000);
220
+ const boundedSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
221
+ const output = createNativeHistoryExport(onRecord, { signal: boundedSignal });
222
+ const [conversation] = await inspect();
223
+ if (!conversation) throw new Error("Claude conversation disappeared before export.");
224
+ for (const file of conversation.files.filter(isTranscript)) {
225
+ const branchId = path.relative(projects, file.path).split(path.sep).join("/");
226
+ if (!file.size) continue;
227
+ const stream = createReadStream(file.path, { end: file.size - 1, signal: boundedSignal });
228
+ for await (const frame of readClaudeJsonFrames(stream)) {
229
+ const provenance = { branchId,
230
+ ...(frame.uuid || frame.message?.id ? { messageId: frame.uuid || frame.message.id } : {}),
231
+ ...(frame.parentUuid ? { parentMessageId: frame.parentUuid } : {}),
232
+ ...(frame.timestamp ? { createdAt: frame.timestamp } : {}),
233
+ ...(frame.message?.model ? { modelId: frame.message.model } : {}) };
234
+ const text = claudeMessageBlocks(frame, { includeNested: true })
235
+ .filter((message) => message.role !== "thinking")
236
+ .map((message) => ({ ...provenance, role: "assistant", text: message.text }));
237
+ if (frame.type === "user") {
238
+ const content = frame.message?.content;
239
+ const user = typeof content === "string" ? content : (Array.isArray(content) ? content : [])
240
+ .filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n\n");
241
+ if (user) text.push({ ...provenance, role: "user", text: user });
242
+ }
243
+ if (frame.attachment?.type === "goal_status" && typeof frame.attachment.condition === "string") {
244
+ text.push({ ...provenance, role: "goal", text: frame.attachment.condition });
245
+ }
246
+ await output.emit({ type: "frame", branchId, frame, text });
247
+ }
248
+ }
249
+ return output.complete();
250
+ },
251
+ remove: async (records) => {
252
+ await requireIdle();
253
+ for (const target of records[0].paths) await rm(target, { recursive: true, force: true });
254
+ }
255
+ });
256
+ }