@rynx-ai/runtime 0.1.11-beta.37 → 0.1.11-beta.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude/native-bridge.d.ts +85 -0
- package/dist/claude/native-bridge.js +335 -17
- package/dist/claude/native-hook-main.js +18 -1
- package/dist/claude/native-hooks.js +7 -0
- package/dist/claude/native-integration.d.ts +120 -18
- package/dist/claude/native-integration.js +1200 -161
- package/dist/claude/transcript-clone.d.ts +18 -0
- package/dist/claude/transcript-clone.js +497 -0
- package/dist/claude/transcript.d.ts +27 -4
- package/dist/claude/transcript.js +131 -30
- package/dist/codex-session-store.d.ts +23 -0
- package/dist/codex-session-store.js +21 -0
- package/dist/host.d.ts +31 -2
- package/dist/host.js +551 -72
- package/dist/runner/child.d.ts +29 -5
- package/dist/runner/child.js +635 -54
- package/dist/runner/manager.d.ts +25 -0
- package/dist/runner/manager.js +805 -115
- package/dist/runner/protocol.d.ts +76 -3
- package/dist/runner/transport.d.ts +9 -0
- package/dist/runner/transport.js +39 -12
- package/package.json +2 -2
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface ClonedClaudeTranscript {
|
|
2
|
+
transcriptPath: string;
|
|
3
|
+
prefixBytes?: number;
|
|
4
|
+
}
|
|
5
|
+
/** Claude names a project's transcript directory by replacing every
|
|
6
|
+
* non-alphanumeric character in its absolute cwd with `-`. */
|
|
7
|
+
export declare function claudeProjectDirectoryName(cwd: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Copy an existing Claude transcript into the target workspace under the
|
|
10
|
+
* target native Session id. The copy is fully committed before Claude starts,
|
|
11
|
+
* so the caller can seed its forwarder at the returned exact byte boundary.
|
|
12
|
+
*/
|
|
13
|
+
export declare function cloneClaudeTranscript({ sourceClaudeSessionId, targetClaudeSessionId, targetCwd, projectsRoot, }: {
|
|
14
|
+
sourceClaudeSessionId: string;
|
|
15
|
+
targetClaudeSessionId: string;
|
|
16
|
+
targetCwd: string;
|
|
17
|
+
projectsRoot?: string;
|
|
18
|
+
}): Promise<ClonedClaudeTranscript | null>;
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { mkdir, open, readdir, realpath, rename, rm, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import { getRuntimeProfile, resolveRuntimeSessionsRoot, } from "@rynx-ai/core";
|
|
7
|
+
const CLAUDE_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
/** Claude names a project's transcript directory by replacing every
|
|
9
|
+
* non-alphanumeric character in its absolute cwd with `-`. */
|
|
10
|
+
export function claudeProjectDirectoryName(cwd) {
|
|
11
|
+
return path.resolve(cwd).replace(/[^A-Za-z0-9]/g, "-");
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Copy an existing Claude transcript into the target workspace under the
|
|
15
|
+
* target native Session id. The copy is fully committed before Claude starts,
|
|
16
|
+
* so the caller can seed its forwarder at the returned exact byte boundary.
|
|
17
|
+
*/
|
|
18
|
+
export async function cloneClaudeTranscript({ sourceClaudeSessionId, targetClaudeSessionId, targetCwd, projectsRoot = resolveRuntimeSessionsRoot(getRuntimeProfile("claude")), }) {
|
|
19
|
+
if (!CLAUDE_SESSION_ID_RE.test(sourceClaudeSessionId) ||
|
|
20
|
+
!CLAUDE_SESSION_ID_RE.test(targetClaudeSessionId)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const resolvedTargetCwd = await realpath(targetCwd).catch(() => path.resolve(targetCwd));
|
|
24
|
+
const targetDirectory = path.join(projectsRoot, claudeProjectDirectoryName(resolvedTargetCwd));
|
|
25
|
+
const targetPath = path.join(targetDirectory, `${targetClaudeSessionId}.jsonl`);
|
|
26
|
+
const sourcePath = await findClaudeTranscript(projectsRoot, sourceClaudeSessionId, targetPath);
|
|
27
|
+
if (!sourcePath)
|
|
28
|
+
return null;
|
|
29
|
+
await mkdir(targetDirectory, { recursive: true, mode: 0o700 });
|
|
30
|
+
const temporaryPath = `${targetPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
31
|
+
let targetHandle;
|
|
32
|
+
try {
|
|
33
|
+
targetHandle = await open(temporaryPath, "wx", 0o600);
|
|
34
|
+
const sourceLines = createInterface({
|
|
35
|
+
input: createReadStream(sourcePath, { encoding: "utf8" }),
|
|
36
|
+
crlfDelay: Infinity,
|
|
37
|
+
});
|
|
38
|
+
let lineNumber = 0;
|
|
39
|
+
for await (const line of sourceLines) {
|
|
40
|
+
lineNumber += 1;
|
|
41
|
+
if (!line.trim()) {
|
|
42
|
+
await targetHandle.write("\n");
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
let record;
|
|
46
|
+
try {
|
|
47
|
+
record = JSON.parse(line);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new Error(`cannot clone malformed Claude transcript ${sourcePath}: line ${lineNumber} is not valid JSON`, { cause: error });
|
|
51
|
+
}
|
|
52
|
+
if (isRecord(record)) {
|
|
53
|
+
if (typeof record.cwd === "string")
|
|
54
|
+
record.cwd = resolvedTargetCwd;
|
|
55
|
+
if (typeof record.sessionId === "string") {
|
|
56
|
+
record.sessionId = targetClaudeSessionId;
|
|
57
|
+
}
|
|
58
|
+
sanitizeClonedToolResultRecord(record);
|
|
59
|
+
}
|
|
60
|
+
// readline returns a complete final record even when the source omitted
|
|
61
|
+
// its final newline. Always terminate it before Claude can append.
|
|
62
|
+
await targetHandle.write(`${JSON.stringify(record)}\n`);
|
|
63
|
+
}
|
|
64
|
+
await targetHandle.sync();
|
|
65
|
+
await targetHandle.close();
|
|
66
|
+
targetHandle = undefined;
|
|
67
|
+
await rename(temporaryPath, targetPath);
|
|
68
|
+
let prefixBytes;
|
|
69
|
+
try {
|
|
70
|
+
prefixBytes = (await stat(targetPath)).size;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// The clone is already atomically committed. The caller still resumes
|
|
74
|
+
// it and falls back to live-EOF seeding if this measurement is unavailable.
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
transcriptPath: targetPath,
|
|
78
|
+
...(prefixBytes === undefined ? {} : { prefixBytes }),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
await targetHandle?.close().catch(() => undefined);
|
|
83
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const MIN_IMAGE_BYTES = 16;
|
|
87
|
+
const MAX_INVALID_IMAGE_REPLAY_CHARS = 8 * 1024;
|
|
88
|
+
const MIN_UNTOLD_BINARY_CHARS = 64;
|
|
89
|
+
const MIN_REDACTABLE_DATA_URI_PAYLOAD = 128;
|
|
90
|
+
const BINARY_BLOCK_TYPES = new Set(["image", "document", "file"]);
|
|
91
|
+
const NON_BINARY_SOURCE_TYPES = new Set(["text", "url", "content", "file"]);
|
|
92
|
+
const INLINE_BASE64_DATA_URI_RE = /data:([^;,\s]*)(?:;[^;,\r\n]*)*;base64,[ \t]?([A-Za-z0-9+/=_-]+)/gi;
|
|
93
|
+
/** Repair a pre-fix Claude record whose image payload is duplicated in both
|
|
94
|
+
* tool_result content and toolUseResult metadata. The model keeps one
|
|
95
|
+
* structured image; replay-only metadata is redacted. */
|
|
96
|
+
function sanitizeClonedToolResultRecord(record) {
|
|
97
|
+
const message = asRecord(record.message);
|
|
98
|
+
if (!message || !Array.isArray(message.content))
|
|
99
|
+
return;
|
|
100
|
+
for (const value of message.content) {
|
|
101
|
+
const block = asRecord(value);
|
|
102
|
+
if (!block || block.type !== "tool_result")
|
|
103
|
+
continue;
|
|
104
|
+
const content = block.content;
|
|
105
|
+
let normalized;
|
|
106
|
+
if (typeof content === "string") {
|
|
107
|
+
const collapsed = stripUnparseableImageOutput(content);
|
|
108
|
+
normalized = rehydrateToolResult(collapsed);
|
|
109
|
+
if (collapsed !== content && normalized.blocks) {
|
|
110
|
+
block.content = normalized.blocks;
|
|
111
|
+
record.toolUseResult = JSON.stringify(redactBinaryPayloads(normalized.blocks));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else if (Array.isArray(content)) {
|
|
116
|
+
normalized = blocksFromParsedList(content);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const blocks = normalized.blocks;
|
|
122
|
+
if (!blocks)
|
|
123
|
+
continue;
|
|
124
|
+
const payloads = imagePayloadsInBlocks(blocks);
|
|
125
|
+
if (normalized.droppedOversizedImage && payloads.length === 0) {
|
|
126
|
+
block.content = blocks;
|
|
127
|
+
record.toolUseResult = JSON.stringify(redactBinaryPayloads(blocks));
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (payloads.length === 0)
|
|
131
|
+
continue;
|
|
132
|
+
block.content = blocks;
|
|
133
|
+
const metadata = record.toolUseResult;
|
|
134
|
+
const metadataText = typeof metadata === "string"
|
|
135
|
+
? metadata
|
|
136
|
+
: metadata === undefined
|
|
137
|
+
? ""
|
|
138
|
+
: JSON.stringify(metadata);
|
|
139
|
+
if (!carriesAnyPayload(metadataText, payloads))
|
|
140
|
+
continue;
|
|
141
|
+
let candidate = typeof metadata === "string"
|
|
142
|
+
? jsonSafeRedactedToolUseResult(metadata)
|
|
143
|
+
: redactBinaryPayloads(metadata);
|
|
144
|
+
const candidateText = typeof candidate === "string"
|
|
145
|
+
? candidate
|
|
146
|
+
: JSON.stringify(candidate);
|
|
147
|
+
if (carriesAnyPayload(candidateText, payloads)) {
|
|
148
|
+
candidate = JSON.stringify(redactBinaryPayloads(blocks));
|
|
149
|
+
}
|
|
150
|
+
record.toolUseResult = candidate;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function stripUnparseableImageOutput(output) {
|
|
154
|
+
const hasErrorPrefix = output.startsWith("Error: ");
|
|
155
|
+
const body = hasErrorPrefix ? output.slice("Error: ".length) : output;
|
|
156
|
+
if (body.includes("\n") ||
|
|
157
|
+
!/^\s*[\[{]/.test(body) ||
|
|
158
|
+
!/"type"\s*:\s*"image"/.test(body) ||
|
|
159
|
+
!/"data"\s*:/.test(body)) {
|
|
160
|
+
return output;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
JSON.parse(body);
|
|
164
|
+
return output;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
const blocks = [];
|
|
168
|
+
if (hasErrorPrefix)
|
|
169
|
+
blocks.push({ type: "text", text: "Error:" });
|
|
170
|
+
blocks.push({ type: "text", text: imageOmittedPlaceholder(undefined) });
|
|
171
|
+
return JSON.stringify(blocks);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function rehydrateToolResult(output) {
|
|
175
|
+
const direct = rehydrateToolResultShape(output);
|
|
176
|
+
if (direct.blocks || !output.startsWith("Error: "))
|
|
177
|
+
return direct;
|
|
178
|
+
const nested = rehydrateToolResultShape(output.slice("Error: ".length));
|
|
179
|
+
if (!nested.blocks ||
|
|
180
|
+
(imagePayloadsInBlocks(nested.blocks).length === 0 && !nested.droppedOversizedImage)) {
|
|
181
|
+
return direct;
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
blocks: [{ type: "text", text: "Error:" }, ...nested.blocks],
|
|
185
|
+
...(nested.droppedOversizedImage ? { droppedOversizedImage: true } : {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function rehydrateToolResultShape(output) {
|
|
189
|
+
let parsed;
|
|
190
|
+
try {
|
|
191
|
+
parsed = JSON.parse(output);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
parsed = undefined;
|
|
195
|
+
}
|
|
196
|
+
if (Array.isArray(parsed))
|
|
197
|
+
return blocksFromParsedList(parsed);
|
|
198
|
+
if (isRecord(parsed)) {
|
|
199
|
+
const image = canonicalImageBlock(parsed);
|
|
200
|
+
if (image)
|
|
201
|
+
return { blocks: [image] };
|
|
202
|
+
if (oversizedInvalidImage(parsed)) {
|
|
203
|
+
return {
|
|
204
|
+
blocks: [{ type: "text", text: imageOmittedPlaceholder(imageMediaType(parsed)) }],
|
|
205
|
+
droppedOversizedImage: true,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return { blocks: null };
|
|
209
|
+
}
|
|
210
|
+
if (parsed !== undefined)
|
|
211
|
+
return { blocks: null };
|
|
212
|
+
const blocks = [];
|
|
213
|
+
let textLines = [];
|
|
214
|
+
let foundImage = false;
|
|
215
|
+
let droppedOversizedImage = false;
|
|
216
|
+
const flushText = () => {
|
|
217
|
+
if (textLines.length === 0)
|
|
218
|
+
return;
|
|
219
|
+
const text = textLines.join("\n");
|
|
220
|
+
textLines = [];
|
|
221
|
+
if (text)
|
|
222
|
+
blocks.push({ type: "text", text });
|
|
223
|
+
};
|
|
224
|
+
for (const line of output.split("\n")) {
|
|
225
|
+
let candidate;
|
|
226
|
+
try {
|
|
227
|
+
candidate = JSON.parse(line);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
candidate = undefined;
|
|
231
|
+
}
|
|
232
|
+
const image = isRecord(candidate) ? canonicalImageBlock(candidate) : null;
|
|
233
|
+
if (image) {
|
|
234
|
+
flushText();
|
|
235
|
+
blocks.push(image);
|
|
236
|
+
foundImage = true;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (isRecord(candidate) && oversizedInvalidImage(candidate)) {
|
|
240
|
+
flushText();
|
|
241
|
+
blocks.push({ type: "text", text: imageOmittedPlaceholder(imageMediaType(candidate)) });
|
|
242
|
+
foundImage = true;
|
|
243
|
+
droppedOversizedImage = true;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (candidate === undefined &&
|
|
247
|
+
line.trimStart().startsWith("{") &&
|
|
248
|
+
holdsClippedImagePayload(line)) {
|
|
249
|
+
flushText();
|
|
250
|
+
blocks.push({ type: "text", text: imageOmittedPlaceholder(undefined) });
|
|
251
|
+
foundImage = true;
|
|
252
|
+
droppedOversizedImage = true;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
textLines.push(line);
|
|
256
|
+
}
|
|
257
|
+
flushText();
|
|
258
|
+
return foundImage ? { blocks, droppedOversizedImage } : { blocks: null };
|
|
259
|
+
}
|
|
260
|
+
function blocksFromParsedList(values) {
|
|
261
|
+
if (values.length === 0)
|
|
262
|
+
return { blocks: null };
|
|
263
|
+
const blocks = [];
|
|
264
|
+
let droppedOversizedImage = false;
|
|
265
|
+
for (const value of values) {
|
|
266
|
+
const block = asRecord(value);
|
|
267
|
+
if (!block)
|
|
268
|
+
return { blocks: null };
|
|
269
|
+
if (block.type === "text") {
|
|
270
|
+
blocks.push(block);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (block.type !== "image")
|
|
274
|
+
return { blocks: null };
|
|
275
|
+
const image = canonicalImageBlock(block);
|
|
276
|
+
if (image) {
|
|
277
|
+
blocks.push(image);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (!oversizedInvalidImage(block))
|
|
281
|
+
return { blocks: null };
|
|
282
|
+
blocks.push({ type: "text", text: imageOmittedPlaceholder(imageMediaType(block)) });
|
|
283
|
+
droppedOversizedImage = true;
|
|
284
|
+
}
|
|
285
|
+
return { blocks, droppedOversizedImage };
|
|
286
|
+
}
|
|
287
|
+
function canonicalImageBlock(value) {
|
|
288
|
+
if (value.type !== "image")
|
|
289
|
+
return null;
|
|
290
|
+
const source = asRecord(value.source);
|
|
291
|
+
if (source) {
|
|
292
|
+
const data = typeof source.data === "string" ? source.data : undefined;
|
|
293
|
+
if (!data)
|
|
294
|
+
return value;
|
|
295
|
+
const mediaType = typeof source.media_type === "string"
|
|
296
|
+
? source.media_type
|
|
297
|
+
: undefined;
|
|
298
|
+
const canonical = mediaType
|
|
299
|
+
? canonicalImagePayload(data, mediaType)
|
|
300
|
+
: whitespaceNormalizedBase64(data);
|
|
301
|
+
const normalized = canonical ?? whitespaceNormalizedBase64(data);
|
|
302
|
+
return normalized && normalized !== data
|
|
303
|
+
? { ...value, source: { ...source, data: normalized } }
|
|
304
|
+
: value;
|
|
305
|
+
}
|
|
306
|
+
const data = typeof value.data === "string" ? value.data : undefined;
|
|
307
|
+
const mediaType = typeof value.mimeType === "string" ? value.mimeType : undefined;
|
|
308
|
+
if (!data || !mediaType)
|
|
309
|
+
return null;
|
|
310
|
+
const canonical = canonicalImagePayload(data, mediaType);
|
|
311
|
+
if (!canonical)
|
|
312
|
+
return null;
|
|
313
|
+
return {
|
|
314
|
+
type: "image",
|
|
315
|
+
source: { type: "base64", media_type: mediaType, data: canonical },
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function whitespaceNormalizedBase64(data) {
|
|
319
|
+
const compact = data.replace(/\s+/g, "");
|
|
320
|
+
if (!compact || compact === data || !/^[A-Za-z0-9+/]*={0,2}$/.test(compact))
|
|
321
|
+
return null;
|
|
322
|
+
const padded = compact.padEnd(Math.ceil(compact.length / 4) * 4, "=");
|
|
323
|
+
return strictBase64Bytes(padded) ? padded : null;
|
|
324
|
+
}
|
|
325
|
+
function canonicalImagePayload(data, mediaType) {
|
|
326
|
+
const compact = data.replace(/\s+/g, "");
|
|
327
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(compact))
|
|
328
|
+
return null;
|
|
329
|
+
const padded = compact.padEnd(Math.ceil(compact.length / 4) * 4, "=");
|
|
330
|
+
const bytes = strictBase64Bytes(padded);
|
|
331
|
+
if (!bytes)
|
|
332
|
+
return null;
|
|
333
|
+
if (bytes.length < MIN_IMAGE_BYTES)
|
|
334
|
+
return null;
|
|
335
|
+
const valid = mediaType === "image/png"
|
|
336
|
+
? bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
337
|
+
: mediaType === "image/jpeg"
|
|
338
|
+
? bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff
|
|
339
|
+
: mediaType === "image/gif"
|
|
340
|
+
? (bytes.subarray(0, 6).toString("ascii") === "GIF87a" ||
|
|
341
|
+
bytes.subarray(0, 6).toString("ascii") === "GIF89a")
|
|
342
|
+
: mediaType === "image/webp"
|
|
343
|
+
? bytes.subarray(0, 4).toString("ascii") === "RIFF" &&
|
|
344
|
+
bytes.subarray(8, 12).toString("ascii") === "WEBP"
|
|
345
|
+
: false;
|
|
346
|
+
return valid ? padded : null;
|
|
347
|
+
}
|
|
348
|
+
function strictBase64Bytes(padded) {
|
|
349
|
+
// Node's base64 decoder is intentionally forgiving (it accepts malformed
|
|
350
|
+
// padding and silently truncates some tails). Claude/Python validate=True is
|
|
351
|
+
// not, so validate complete quartets before decoding.
|
|
352
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(padded)) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
return Buffer.from(padded, "base64");
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function oversizedInvalidImage(value) {
|
|
363
|
+
if (value.type !== "image")
|
|
364
|
+
return false;
|
|
365
|
+
// Anthropic source-shaped blocks have always been passed through; only a
|
|
366
|
+
// rejected MCP bare data/mimeType object is eligible for collapse.
|
|
367
|
+
if (asRecord(value.source))
|
|
368
|
+
return false;
|
|
369
|
+
const data = typeof value.data === "string" ? value.data : undefined;
|
|
370
|
+
return Boolean(data && data.length > MAX_INVALID_IMAGE_REPLAY_CHARS);
|
|
371
|
+
}
|
|
372
|
+
function imageMediaType(value) {
|
|
373
|
+
const source = asRecord(value.source);
|
|
374
|
+
return typeof source?.media_type === "string"
|
|
375
|
+
? source.media_type
|
|
376
|
+
: typeof value.mimeType === "string"
|
|
377
|
+
? value.mimeType
|
|
378
|
+
: undefined;
|
|
379
|
+
}
|
|
380
|
+
function imagePayloadsInBlocks(blocks) {
|
|
381
|
+
const payloads = [];
|
|
382
|
+
for (const block of blocks) {
|
|
383
|
+
const source = asRecord(block.source);
|
|
384
|
+
if (typeof source?.data === "string" && source.data)
|
|
385
|
+
payloads.push(source.data);
|
|
386
|
+
}
|
|
387
|
+
return payloads;
|
|
388
|
+
}
|
|
389
|
+
function redactBinaryPayloads(value) {
|
|
390
|
+
if (Array.isArray(value))
|
|
391
|
+
return value.map(redactBinaryPayloads);
|
|
392
|
+
if (typeof value === "string") {
|
|
393
|
+
return value.replace(INLINE_BASE64_DATA_URI_RE, (match, mediaType, payload) => payload.length >= MIN_REDACTABLE_DATA_URI_PAYLOAD
|
|
394
|
+
? binaryPayloadOmitted(mediaType)
|
|
395
|
+
: match);
|
|
396
|
+
}
|
|
397
|
+
const record = asRecord(value);
|
|
398
|
+
if (!record)
|
|
399
|
+
return value;
|
|
400
|
+
const copy = { ...record };
|
|
401
|
+
if (typeof copy.type === "string" && BINARY_BLOCK_TYPES.has(copy.type)) {
|
|
402
|
+
redactDataField(copy);
|
|
403
|
+
const source = asRecord(copy.source);
|
|
404
|
+
if (source) {
|
|
405
|
+
const sourceCopy = { ...source };
|
|
406
|
+
if (typeof sourceCopy.type !== "string" ||
|
|
407
|
+
!NON_BINARY_SOURCE_TYPES.has(sourceCopy.type)) {
|
|
408
|
+
redactDataField(sourceCopy);
|
|
409
|
+
}
|
|
410
|
+
copy.source = sourceCopy;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
for (const [key, nested] of Object.entries(copy)) {
|
|
414
|
+
copy[key] = redactBinaryPayloads(nested);
|
|
415
|
+
}
|
|
416
|
+
return copy;
|
|
417
|
+
}
|
|
418
|
+
function redactDataField(record) {
|
|
419
|
+
const data = typeof record.data === "string" ? record.data : undefined;
|
|
420
|
+
if (!data || !isBase64Payload(data))
|
|
421
|
+
return;
|
|
422
|
+
const mediaType = typeof record.media_type === "string" ? record.media_type : "";
|
|
423
|
+
record.data = binaryPayloadOmitted(mediaType);
|
|
424
|
+
}
|
|
425
|
+
function isBase64Payload(value) {
|
|
426
|
+
const compact = value.replace(/\s+/g, "");
|
|
427
|
+
return Boolean(compact &&
|
|
428
|
+
/^[A-Za-z0-9+/=_-]+$/.test(compact) &&
|
|
429
|
+
(compact.includes("=") || compact.length % 4 === 0 || compact.length >= MIN_UNTOLD_BINARY_CHARS));
|
|
430
|
+
}
|
|
431
|
+
function binaryPayloadOmitted(mediaType) {
|
|
432
|
+
const label = mediaType || "binary";
|
|
433
|
+
return `[${label} payload omitted from toolUseResult; kept in the tool_result content]`;
|
|
434
|
+
}
|
|
435
|
+
function jsonSafeRedactedToolUseResult(output) {
|
|
436
|
+
try {
|
|
437
|
+
const parsed = JSON.parse(output);
|
|
438
|
+
const redacted = redactBinaryPayloads(parsed);
|
|
439
|
+
return JSON.stringify(redacted) === JSON.stringify(parsed)
|
|
440
|
+
? output
|
|
441
|
+
: JSON.stringify(redacted);
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
return JSON.stringify(output);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function carriesAnyPayload(text, payloads) {
|
|
448
|
+
const compact = text.replace(/\\+[nrtf]/g, "").replace(/[\\\s]+/g, "");
|
|
449
|
+
return payloads.some((payload) => compact.includes(payload) || compact.includes(payload.replace(/=+$/, "")));
|
|
450
|
+
}
|
|
451
|
+
function imageOmittedPlaceholder(mediaType) {
|
|
452
|
+
const label = mediaType ? `${mediaType} image` : "image";
|
|
453
|
+
return `[${label} omitted from history to save context — re-run the tool call above (e.g. Read the same path) to view it again]`;
|
|
454
|
+
}
|
|
455
|
+
function holdsClippedImagePayload(value) {
|
|
456
|
+
return /"type"\s*:\s*"image"/.test(value) && /"data"\s*:/.test(value);
|
|
457
|
+
}
|
|
458
|
+
async function findClaudeTranscript(projectsRoot, claudeSessionId, excludedPath) {
|
|
459
|
+
let entries;
|
|
460
|
+
try {
|
|
461
|
+
entries = await readdir(projectsRoot, { withFileTypes: true });
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
if (isMissingFileError(error))
|
|
465
|
+
return null;
|
|
466
|
+
throw error;
|
|
467
|
+
}
|
|
468
|
+
const filename = `${claudeSessionId}.jsonl`;
|
|
469
|
+
const matches = [];
|
|
470
|
+
for (const entry of entries) {
|
|
471
|
+
if (!entry.isDirectory())
|
|
472
|
+
continue;
|
|
473
|
+
const candidate = path.join(projectsRoot, entry.name, filename);
|
|
474
|
+
if (path.resolve(candidate) === path.resolve(excludedPath))
|
|
475
|
+
continue;
|
|
476
|
+
try {
|
|
477
|
+
const details = await stat(candidate);
|
|
478
|
+
if (details.isFile())
|
|
479
|
+
matches.push({ path: candidate, mtimeMs: details.mtimeMs });
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
if (!isMissingFileError(error))
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
matches.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
|
487
|
+
return matches[0]?.path ?? null;
|
|
488
|
+
}
|
|
489
|
+
function isRecord(value) {
|
|
490
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
491
|
+
}
|
|
492
|
+
function asRecord(value) {
|
|
493
|
+
return isRecord(value) ? value : null;
|
|
494
|
+
}
|
|
495
|
+
function isMissingFileError(error) {
|
|
496
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
497
|
+
}
|
|
@@ -3,6 +3,17 @@ import type { AgentEvent, TerminalCommandData } from "@rynx-ai/core";
|
|
|
3
3
|
* `<project>/<sessionId>/subagents/agent-<agentId>.jsonl`, alongside the parent
|
|
4
4
|
* `<sessionId>.jsonl`. Derive that path from the parent transcript path. */
|
|
5
5
|
export declare function subagentTranscriptPath(parentTranscriptPath: string, agentId: string): string;
|
|
6
|
+
export interface TerminalCommandFragments {
|
|
7
|
+
command?: string;
|
|
8
|
+
stdout?: string;
|
|
9
|
+
stderr?: string;
|
|
10
|
+
/** Marker presence, not output truthiness: Claude can record an empty
|
|
11
|
+
* stdout tag as the authoritative completion half of a split command. */
|
|
12
|
+
hasOutput: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** Parse either half of Claude's shell-mode record. Newer and older Claude
|
|
15
|
+
* builds may split `<bash-input>` from the later stdout/stderr record. */
|
|
16
|
+
export declare function parseTerminalCommandFragments(content: string): TerminalCommandFragments | undefined;
|
|
6
17
|
/**
|
|
7
18
|
* Parse a claude local-command (`!` bash mode) user record's string content into
|
|
8
19
|
* a {@link TerminalCommandData}. Claude records the command and its captured
|
|
@@ -37,13 +48,25 @@ export declare function parseTranscriptLine(line: string, opts?: ParseTranscript
|
|
|
37
48
|
export declare function parseTranscriptRecord(record: unknown, opts?: ParseTranscriptOptions): AgentEvent[];
|
|
38
49
|
/**
|
|
39
50
|
* Whether a transcript is a `/fork` (branch) of another session: claude stamps a
|
|
40
|
-
* `forkedFrom: { sessionId }` marker
|
|
51
|
+
* `forkedFrom: { sessionId }` marker on copied records pointing at the source
|
|
41
52
|
* session (reference implementation's `transcript_has_forked_from_marker`). Used to distinguish a
|
|
42
53
|
* fork from an ordinary `resume` (both arrive as `SessionStart source="resume"`).
|
|
43
|
-
*
|
|
44
|
-
*
|
|
54
|
+
* The marker must belong to the announced target and expected source; sample
|
|
55
|
+
* the first and last 200 records because long copied histories can place it at
|
|
56
|
+
* either edge.
|
|
45
57
|
*/
|
|
46
|
-
export declare function transcriptHasForkedFrom(path: string,
|
|
58
|
+
export declare function transcriptHasForkedFrom(path: string, claudeSessionId: string, sourceClaudeSessionId?: string): boolean;
|
|
59
|
+
/** Claude versions without a copied-record marker still persist `/fork` and
|
|
60
|
+
* `/branch` as a recent top-level local command. Match only the new native
|
|
61
|
+
* Session and the hook's narrow time window. */
|
|
62
|
+
export declare function transcriptHasRecentLocalCommand(path: string, claudeSessionId: string, recordedAtMs: number, commandNames?: ReadonlySet<string>): boolean;
|
|
63
|
+
/** Wait briefly for Claude to flush either fork signal after SessionStart.
|
|
64
|
+
* The observer hook calls this before recording the edge, allowing a
|
|
65
|
+
* one-second late-marker window without delaying ordinary transcript polling. */
|
|
66
|
+
export declare function waitForTranscriptForkSignal(path: string, claudeSessionId: string, sourceClaudeSessionId: string, recordedAtMs: number, options?: {
|
|
67
|
+
timeoutMs?: number;
|
|
68
|
+
pollMs?: number;
|
|
69
|
+
}): Promise<boolean>;
|
|
47
70
|
/**
|
|
48
71
|
* Read a sub-agent (Task) transcript in full and map it to {@link AgentEvent}s
|
|
49
72
|
* tagged with `parentToolUseId`. Called once the parent Task tool_result arrives
|