@relayflows/sdk 2.0.21 → 2.0.22

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 (97) hide show
  1. package/dist/agent-transcript.d.ts +186 -0
  2. package/dist/agent-transcript.d.ts.map +1 -0
  3. package/dist/agent-transcript.js +585 -0
  4. package/dist/agent-transcript.js.map +1 -0
  5. package/dist/cli/run.js +2 -1
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/cli/status.d.ts +34 -0
  8. package/dist/cli/status.d.ts.map +1 -0
  9. package/dist/cli/status.js +307 -0
  10. package/dist/cli/status.js.map +1 -0
  11. package/dist/cli/step-failure.js +31 -7
  12. package/dist/cli/step-failure.js.map +1 -1
  13. package/dist/cli-commands.d.ts +13 -0
  14. package/dist/cli-commands.d.ts.map +1 -1
  15. package/dist/cli-commands.js +11 -0
  16. package/dist/cli-commands.js.map +1 -1
  17. package/dist/cli.d.ts +2 -1
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +9 -0
  20. package/dist/cli.js.map +1 -1
  21. package/dist/failure-kinds.d.ts +15 -2
  22. package/dist/failure-kinds.d.ts.map +1 -1
  23. package/dist/failure-kinds.js +8 -0
  24. package/dist/failure-kinds.js.map +1 -1
  25. package/dist/journal-reader.d.ts +1 -1
  26. package/dist/journal-reader.d.ts.map +1 -1
  27. package/dist/journal-reader.js +3 -1
  28. package/dist/journal-reader.js.map +1 -1
  29. package/dist/llm-worker.d.ts.map +1 -1
  30. package/dist/llm-worker.js +12 -1
  31. package/dist/llm-worker.js.map +1 -1
  32. package/dist/permissions-preflight.d.ts +22 -0
  33. package/dist/permissions-preflight.d.ts.map +1 -0
  34. package/dist/permissions-preflight.js +49 -0
  35. package/dist/permissions-preflight.js.map +1 -0
  36. package/dist/preflight.d.ts.map +1 -1
  37. package/dist/preflight.js +6 -0
  38. package/dist/preflight.js.map +1 -1
  39. package/dist/pty-sidechannel.d.ts +7 -0
  40. package/dist/pty-sidechannel.d.ts.map +1 -1
  41. package/dist/pty-sidechannel.js.map +1 -1
  42. package/dist/redact.d.ts +16 -0
  43. package/dist/redact.d.ts.map +1 -0
  44. package/dist/redact.js +104 -0
  45. package/dist/redact.js.map +1 -0
  46. package/dist/run-state.d.ts +124 -0
  47. package/dist/run-state.d.ts.map +1 -0
  48. package/dist/run-state.js +368 -0
  49. package/dist/run-state.js.map +1 -0
  50. package/dist/spec.d.ts +16 -1
  51. package/dist/spec.d.ts.map +1 -1
  52. package/dist/spec.js.map +1 -1
  53. package/dist/step-env.d.ts +25 -0
  54. package/dist/step-env.d.ts.map +1 -0
  55. package/dist/step-env.js +34 -0
  56. package/dist/step-env.js.map +1 -0
  57. package/dist/transcript-tail.d.ts +54 -0
  58. package/dist/transcript-tail.d.ts.map +1 -0
  59. package/dist/transcript-tail.js +176 -0
  60. package/dist/transcript-tail.js.map +1 -0
  61. package/dist/worker-cli.d.ts +10 -0
  62. package/dist/worker-cli.d.ts.map +1 -1
  63. package/dist/worker-cli.js +158 -16
  64. package/dist/worker-cli.js.map +1 -1
  65. package/dist/worker-usage.d.ts +7 -2
  66. package/dist/worker-usage.d.ts.map +1 -1
  67. package/dist/worker-usage.js +21 -3
  68. package/dist/worker-usage.js.map +1 -1
  69. package/dist/worker.d.ts.map +1 -1
  70. package/dist/worker.js +31 -6
  71. package/dist/worker.js.map +1 -1
  72. package/dist/wrapper-runtime.d.ts +7 -1
  73. package/dist/wrapper-runtime.d.ts.map +1 -1
  74. package/dist/wrapper-runtime.js +7 -1
  75. package/dist/wrapper-runtime.js.map +1 -1
  76. package/package.json +2 -2
  77. package/src/agent-transcript.ts +651 -0
  78. package/src/cli/run.ts +2 -1
  79. package/src/cli/status.ts +329 -0
  80. package/src/cli/step-failure.ts +32 -7
  81. package/src/cli-commands.ts +11 -0
  82. package/src/cli.ts +8 -0
  83. package/src/failure-kinds.ts +15 -1
  84. package/src/journal-reader.ts +4 -2
  85. package/src/llm-worker.ts +12 -1
  86. package/src/permissions-preflight.ts +53 -0
  87. package/src/preflight.ts +6 -0
  88. package/src/pty-sidechannel.ts +7 -0
  89. package/src/redact.ts +106 -0
  90. package/src/run-state.ts +468 -0
  91. package/src/spec.ts +16 -1
  92. package/src/step-env.ts +47 -0
  93. package/src/transcript-tail.ts +193 -0
  94. package/src/worker-cli.ts +157 -17
  95. package/src/worker-usage.ts +26 -3
  96. package/src/worker.ts +32 -6
  97. package/src/wrapper-runtime.ts +7 -1
@@ -0,0 +1,651 @@
1
+ import { constants as fsConstants } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import { mkdir, open, unlink, type FileHandle } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { ptySocketPath, type SidechannelContext } from './pty-sidechannel.js';
6
+
7
+ /**
8
+ * Per-attempt agent transcript: the redacted, capped `stream-json` file the
9
+ * worker writes beside the PTY socket, and the ≤ 8 KiB digest of it that rides
10
+ * in `trajectory_tail` on every completion.
11
+ *
12
+ * Every bound below is enforced here and stated in what it produces — a
13
+ * marker line at a seam, a suffix on a cut string, a boolean on the digest —
14
+ * so a reader is told what was dropped rather than left to guess.
15
+ */
16
+
17
+ /** Cap on one attempt's transcript file: the first 128 KiB whole, then a rolling tail. */
18
+ export const TRANSCRIPT_FILE_MAX_BYTES = 1024 * 1024;
19
+ export const TRANSCRIPT_HEAD_BYTES = 128 * 1024;
20
+ export const TRANSCRIPT_TAIL_BYTES = TRANSCRIPT_FILE_MAX_BYTES - TRANSCRIPT_HEAD_BYTES;
21
+ /** Cap on any one string leaf inside a frame (tool results, thinking, text). */
22
+ export const TRANSCRIPT_STRING_MAX_BYTES = 64 * 1024;
23
+ /**
24
+ * Cap on the serialized digest. Half the kernel's 16 KiB `trajectory_tail`
25
+ * boundary (relayflowd/src/server.rs), leaving room for the `relay_task`
26
+ * sibling the worker already journals.
27
+ */
28
+ export const TRANSCRIPT_DIGEST_MAX_BYTES = 8 * 1024;
29
+ /** Cap on an llm step's `trajectory_tail.error`; over 16 KiB the kernel refuses the completion. */
30
+ export const LLM_ERROR_MAX_BYTES = 4 * 1024;
31
+
32
+ export const FINAL_TEXT_MAX_BYTES = 2 * 1024;
33
+ export const FAILURE_EXCERPT_MAX_BYTES = 1024;
34
+ export const TOOL_INPUT_EXCERPT_MAX_CHARS = 120;
35
+ /**
36
+ * Cap on any single provider-supplied identifier copied into `result`
37
+ * (`model`, `session_id`, `stop_reason`, `subtype`, `claude_code_version`) and
38
+ * on a tool name. These are attacker-influenceable strings that no reduction
39
+ * step in `boundTranscriptDigest` trims, so they are bounded where they enter.
40
+ */
41
+ export const DIGEST_LABEL_MAX_BYTES = 256;
42
+ /**
43
+ * Bytes reserved inside `TRANSCRIPT_FILE_MAX_BYTES` for the
44
+ * `relayflow.truncated` marker, so a full head plus a full tail plus the
45
+ * marker still fits the stated per-attempt cap. The marker serializes to
46
+ * well under this even with 20-digit counters.
47
+ */
48
+ export const TRUNCATION_MARKER_RESERVE = 256;
49
+ export const TOOL_COUNTS_MAX = 32;
50
+ export const TOOL_LAST_CALLS_MAX = 20;
51
+ export const ARTIFACT_PATHS_MAX = 50;
52
+
53
+ /** The `system/init` keys kept; everything else on that frame is local paths and inventory. */
54
+ const INIT_KEEP = ['type', 'subtype', 'model', 'claude_code_version', 'permissionMode', 'session_id'] as const;
55
+
56
+ export interface TranscriptFile {
57
+ path: string;
58
+ /** Bytes the redacted transcript would have occupied without the file cap. */
59
+ bytes_total: number;
60
+ bytes_kept: number;
61
+ frames_total: number;
62
+ frames_kept: number;
63
+ truncated: boolean;
64
+ sha256: string;
65
+ }
66
+
67
+ export interface TranscriptToolCall {
68
+ seq: number;
69
+ name: string;
70
+ input_excerpt: string;
71
+ result_bytes?: number;
72
+ is_error?: boolean;
73
+ }
74
+
75
+ export interface TranscriptDigest {
76
+ attempt?: number;
77
+ exit_code?: number | null;
78
+ file?: TranscriptFile;
79
+ result?: {
80
+ provider: 'claude' | 'codex';
81
+ model?: string;
82
+ claude_code_version?: string;
83
+ session_id?: string;
84
+ subtype?: string;
85
+ is_error?: boolean;
86
+ stop_reason?: string;
87
+ num_turns?: number;
88
+ duration_ms?: number;
89
+ duration_api_ms?: number;
90
+ total_cost_usd?: number;
91
+ usage?: { input?: number; output?: number; cache_read?: number; cache_creation?: number; thinking?: number };
92
+ permission_denials?: number;
93
+ };
94
+ tools?: {
95
+ counts: Array<{ name: string; calls: number; errors: number }>;
96
+ last_calls: TranscriptToolCall[];
97
+ total_calls: number;
98
+ shown_calls: number;
99
+ /** False when the provider's frames are only counted, not paired (Codex). */
100
+ complete: boolean;
101
+ };
102
+ final_text?: string;
103
+ final_text_truncated?: boolean;
104
+ failure?: { kind: 'result' | 'tool_result' | 'stderr'; excerpt: string; truncated?: boolean };
105
+ artifacts?: { count: number; paths: string[] };
106
+ /**
107
+ * Set when even the ordered reductions left the digest over its cap and it
108
+ * was cut back to fixed-size fields — every provider-supplied string but a
109
+ * bounded file path is gone. A reader is told rather than left guessing.
110
+ */
111
+ core_only?: boolean;
112
+ }
113
+
114
+ type RecordValue = Record<string, unknown>;
115
+ const record = (value: unknown): value is RecordValue => typeof value === 'object' && value !== null && !Array.isArray(value);
116
+
117
+ /** `…/runs/<runId>/steps/<stepId>/attempt-<n>.transcript.jsonl`, beside the PTY socket. */
118
+ export function transcriptPath(
119
+ context: Pick<SidechannelContext, 'dataDir' | 'runId' | 'stepId'>,
120
+ attempt: number,
121
+ ): string {
122
+ if (!Number.isSafeInteger(attempt) || attempt < 1) throw new Error('Invalid transcript attempt');
123
+ return join(dirname(ptySocketPath(context)), `attempt-${attempt}.transcript.jsonl`);
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Redaction
128
+ // ---------------------------------------------------------------------------
129
+
130
+ const SECRET_NAME = /(TOKEN|SECRET|KEY|PASSWORD|PASSWD|CREDENTIAL|AUTH|COOKIE|SESSION|PRIVATE)/i;
131
+ /**
132
+ * Relay-issued token prefixes, shared with `redactRelayError` (worker-cli.ts).
133
+ * The tail must be at least 8 characters so journal keys such as `at_ms` are
134
+ * not mistaken for tokens.
135
+ */
136
+ export const RELAY_TOKEN_PATTERN = /\b(?:at|rk|nt|ot|br|arr)_(?:live_)?[A-Za-z0-9_-]{8,}/g;
137
+ const TOKEN_PATTERNS: RegExp[] = [
138
+ RELAY_TOKEN_PATTERN,
139
+ /\bsk-ant-[A-Za-z0-9_-]{8,}/g,
140
+ /\b(?:ghp|gho|ghs|ghu|ghr)_[A-Za-z0-9]{16,}/g,
141
+ /\bgithub_pat_[A-Za-z0-9_]{16,}/g,
142
+ /\bxox[abps]-[A-Za-z0-9-]{8,}/g,
143
+ /\bAKIA[0-9A-Z]{16}\b/g,
144
+ ];
145
+ const PEM_BLOCK = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
146
+ // Header forms, in prose or inside a JSON-encoded object: the header name and
147
+ // any `Bearer`/`Basic` scheme stay, the value goes. Cookies are redacted to the
148
+ // end of the line, since every pair in them is a credential.
149
+ const HEADER_VALUE = /\b(authorization|x-api-key|x-callback-token)("?\s*[:=]\s*"?)((?:Bearer|Basic)\s+)?(?!\[redacted)([^\s"',;\\]+)/gi;
150
+ const COOKIE_VALUE = /\b(cookie|set-cookie)("?\s*[:=]\s*"?)(?!\[redacted)([^\n"\\]+)/gi;
151
+ const BEARER = /\bBearer\s+(?!\[redacted)[A-Za-z0-9._~+/=-]{8,}/g;
152
+ const ENV_LINE = /^([A-Za-z_][A-Za-z0-9_]*)=(.+)$/gm;
153
+
154
+ export type Redactor = (text: string) => string;
155
+
156
+ /**
157
+ * One redactor for one environment: the values of secret-named variables are
158
+ * resolved once, so a writer redacting thousands of strings does not rescan
159
+ * `process.env` for each.
160
+ */
161
+ export function createRedactor(env: NodeJS.ProcessEnv = process.env): Redactor {
162
+ const byName: Array<[string, string]> = [];
163
+ for (const [name, value] of Object.entries(env)) {
164
+ if (typeof value === 'string' && value.length >= 8 && SECRET_NAME.test(name)) byName.push([name, value]);
165
+ }
166
+ // Longest first, so a value that is a prefix of another cannot leave the
167
+ // longer one half-redacted.
168
+ byName.sort((a, b) => b[1].length - a[1].length);
169
+ return (text: string): string => {
170
+ if (text.length === 0) return text;
171
+ // Rule 3 — env dumps by shape: `NAME=VALUE` where VALUE is that variable's
172
+ // value, whatever the name. Catches `env`/`printenv` output of variables
173
+ // whose names look harmless.
174
+ let out = text.replace(ENV_LINE, (line, name: string, value: string) =>
175
+ env[name] === value ? `${name}=[redacted:${name}]` : line);
176
+ // Rule 2 — secret-named variables' values, wherever they appear.
177
+ for (const [name, value] of byName) out = out.replaceAll(value, `[redacted:${name}]`);
178
+ // Rule 4 — well-known token shapes and header forms; the header name stays.
179
+ out = out.replace(PEM_BLOCK, '[redacted:private-key]');
180
+ for (const pattern of TOKEN_PATTERNS) out = out.replace(pattern, '[redacted]');
181
+ out = out.replace(HEADER_VALUE, (_m, name: string, sep: string, scheme: string | undefined) => `${name}${sep}${scheme ?? ''}[redacted]`);
182
+ out = out.replace(COOKIE_VALUE, (_m, name: string, sep: string) => `${name}${sep}[redacted]`);
183
+ out = out.replace(BEARER, 'Bearer [redacted]');
184
+ return out;
185
+ };
186
+ }
187
+
188
+ /** Redact one string against `env` (D6 rules 2–4). */
189
+ export function redactText(text: string, env: NodeJS.ProcessEnv = process.env): string {
190
+ return createRedactor(env)(text);
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Bounded strings
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /** Cut `text` to at most `maxBytes` of UTF-8 on a code-point boundary. */
198
+ export function utf8Head(text: string, maxBytes: number): string {
199
+ const bytes = Buffer.from(text, 'utf8');
200
+ if (bytes.length <= maxBytes) return text;
201
+ let end = maxBytes;
202
+ while (end > 0 && (bytes[end]! & 0xc0) === 0x80) end -= 1;
203
+ return bytes.subarray(0, end).toString('utf8');
204
+ }
205
+
206
+ /** The last `maxBytes` of `text`, on a code-point boundary. */
207
+ export function utf8Tail(text: string, maxBytes: number): string {
208
+ const bytes = Buffer.from(text, 'utf8');
209
+ if (bytes.length <= maxBytes) return text;
210
+ let start = bytes.length - maxBytes;
211
+ while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1;
212
+ return bytes.subarray(start).toString('utf8');
213
+ }
214
+
215
+ /** Head-cut with the truncation stated in the value itself. */
216
+ export function boundedText(text: string, maxBytes: number, label = ''): { text: string; truncated: boolean } {
217
+ const total = Buffer.byteLength(text, 'utf8');
218
+ if (total <= maxBytes) return { text, truncated: false };
219
+ const head = utf8Head(text, maxBytes);
220
+ return { text: `${head}…[${label}${total - Buffer.byteLength(head, 'utf8')} bytes truncated]`, truncated: true };
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Frame reduction
225
+ // ---------------------------------------------------------------------------
226
+
227
+ /**
228
+ * Redact and bound every string leaf of a parsed frame, and reduce the
229
+ * `system/init` frame to its whitelist. Unknown frame types are kept as they
230
+ * are, minus the same string treatment.
231
+ */
232
+ export function reduceFrame(frame: unknown, redact: Redactor): unknown {
233
+ if (record(frame) && frame.type === 'system' && frame.subtype === 'init') {
234
+ const kept: RecordValue = { relayflow_reduced: true };
235
+ for (const key of INIT_KEEP) if (frame[key] !== undefined) kept[key] = frame[key];
236
+ if (Array.isArray(frame.tools)) kept.tools_count = frame.tools.length;
237
+ if (Array.isArray(frame.mcp_servers)) kept.mcp_servers_count = frame.mcp_servers.length;
238
+ return reduceValue(kept, redact);
239
+ }
240
+ return reduceValue(frame, redact);
241
+ }
242
+
243
+ function reduceValue(value: unknown, redact: Redactor): unknown {
244
+ if (typeof value === 'string') return boundedText(redact(value), TRANSCRIPT_STRING_MAX_BYTES, 'relayflow: ').text;
245
+ if (Array.isArray(value)) return value.map(item => reduceValue(item, redact));
246
+ if (record(value)) {
247
+ const out: RecordValue = {};
248
+ for (const [key, item] of Object.entries(value)) out[key] = reduceValue(item, redact);
249
+ return out;
250
+ }
251
+ return value;
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // File writer
256
+ // ---------------------------------------------------------------------------
257
+
258
+ export interface TranscriptWriter {
259
+ /** One stdout line, without its newline. Never throws; a failed file is reported by `close`. */
260
+ write(line: string): void;
261
+ /** Flush the tail, finish the file, and describe it — `undefined` when the file could not be written. */
262
+ close(): Promise<TranscriptFile | undefined>;
263
+ }
264
+
265
+ /**
266
+ * Open the per-attempt transcript file. The first `TRANSCRIPT_HEAD_BYTES` are
267
+ * written as they arrive, so a crash mid-run still leaves the start of the
268
+ * transcript on disk; past that a ring of the last `TRANSCRIPT_TAIL_BYTES`
269
+ * is kept and flushed on close, behind one `relayflow.truncated` marker line
270
+ * that says how much fell between them.
271
+ */
272
+ export async function openTranscriptWriter(
273
+ path: string,
274
+ env: NodeJS.ProcessEnv = process.env,
275
+ ): Promise<TranscriptWriter | undefined> {
276
+ let handle: FileHandle;
277
+ try {
278
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
279
+ // The agent runs as this OS user, so it can plant a symlink at the
280
+ // transcript path; `open(path, 'w')` would follow it and truncate whatever
281
+ // it points at. Remove whatever is there without following it, then create
282
+ // the file exclusively so a re-planted symlink loses the race rather than
283
+ // being followed. O_NOFOLLOW is belt to O_EXCL's braces.
284
+ try { await unlink(path); } catch { /* absent, or not ours to remove */ }
285
+ handle = await open(
286
+ path,
287
+ // eslint-disable-next-line no-bitwise
288
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
289
+ 0o600,
290
+ );
291
+ } catch {
292
+ return undefined;
293
+ }
294
+ const redact = createRedactor(env);
295
+ const hash = createHash('sha256');
296
+ const tail: Buffer[] = [];
297
+ let pending: Promise<void> = Promise.resolve();
298
+ let failed = false;
299
+ let headBytes = 0;
300
+ let tailBytes = 0;
301
+ let bytesTotal = 0;
302
+ let bytesDropped = 0;
303
+ let framesTotal = 0;
304
+ let framesDropped = 0;
305
+ let closed = false;
306
+
307
+ const append = (bytes: Buffer): void => {
308
+ hash.update(bytes);
309
+ pending = pending.then(async () => { if (!failed) await handle.write(bytes); }).catch(() => { failed = true; });
310
+ };
311
+
312
+ return {
313
+ write(line: string): void {
314
+ if (closed || line.length === 0) return;
315
+ let reduced: string;
316
+ try {
317
+ const frame: unknown = JSON.parse(line);
318
+ reduced = JSON.stringify(reduceFrame(frame, redact));
319
+ } catch {
320
+ // Not a frame: kept opaque, still redacted and bounded.
321
+ reduced = boundedText(redact(line), TRANSCRIPT_STRING_MAX_BYTES, 'relayflow: ').text;
322
+ }
323
+ const bytes = Buffer.from(`${reduced}\n`, 'utf8');
324
+ framesTotal += 1;
325
+ bytesTotal += bytes.length;
326
+ if (tail.length === 0 && headBytes + bytes.length <= TRANSCRIPT_HEAD_BYTES) {
327
+ headBytes += bytes.length;
328
+ append(bytes);
329
+ return;
330
+ }
331
+ tail.push(bytes);
332
+ tailBytes += bytes.length;
333
+ // Once anything has been dropped, `close` will write a truncation marker
334
+ // that is part of the file too, so the tail budget shrinks by its
335
+ // reservation — otherwise a full head plus a full tail plus the marker
336
+ // puts the file over TRANSCRIPT_FILE_MAX_BYTES.
337
+ const tailLimit = (): number =>
338
+ TRANSCRIPT_TAIL_BYTES - (bytesDropped > 0 ? TRUNCATION_MARKER_RESERVE : 0);
339
+ while (tailBytes > tailLimit() && tail.length > 0) {
340
+ const dropped = tail.shift()!;
341
+ tailBytes -= dropped.length;
342
+ bytesDropped += dropped.length;
343
+ framesDropped += 1;
344
+ }
345
+ },
346
+ async close(): Promise<TranscriptFile | undefined> {
347
+ if (closed) return undefined;
348
+ closed = true;
349
+ if (bytesDropped > 0) {
350
+ append(Buffer.from(`${JSON.stringify({
351
+ type: 'relayflow.truncated', bytes_dropped: bytesDropped, frames_dropped: framesDropped,
352
+ })}\n`, 'utf8'));
353
+ }
354
+ for (const bytes of tail) append(bytes);
355
+ await pending;
356
+ try { await handle.close(); } catch { failed = true; }
357
+ if (failed) return undefined;
358
+ return {
359
+ path,
360
+ bytes_total: bytesTotal,
361
+ bytes_kept: headBytes + tailBytes,
362
+ frames_total: framesTotal,
363
+ frames_kept: framesTotal - framesDropped,
364
+ truncated: bytesDropped > 0,
365
+ sha256: hash.digest('hex'),
366
+ };
367
+ },
368
+ };
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Digest
373
+ // ---------------------------------------------------------------------------
374
+
375
+ const num = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
376
+ const str = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined;
377
+
378
+ /**
379
+ * Build the digest from the frames `decodeProviderResult` already parsed.
380
+ * Claude: `tool_use` blocks are paired with `tool_result` blocks by
381
+ * `tool_use_id`; `init` supplies model and version. Codex: `item.completed`
382
+ * items are counted by `item.type` only, so `tools.complete` is false.
383
+ * `failure` is filled only when the attempt did not succeed.
384
+ */
385
+ export function buildTranscriptDigest(
386
+ frames: RecordValue[],
387
+ kind: 'claude' | 'codex',
388
+ outcome: { exit_code: number | null; stderr_tail: string },
389
+ env: NodeJS.ProcessEnv = process.env,
390
+ ): Pick<TranscriptDigest, 'result' | 'tools' | 'final_text' | 'final_text_truncated' | 'failure'> {
391
+ const redact = createRedactor(env);
392
+ const digest: ReturnType<typeof buildTranscriptDigest> = {};
393
+ const bounded = (text: string, max: number): { text: string; truncated: boolean } => boundedText(redact(text), max);
394
+
395
+ if (kind === 'claude') {
396
+ const init = frames.find(f => f.type === 'system' && f.subtype === 'init');
397
+ const terminal = [...frames].reverse().find(f => f.type === 'result');
398
+ const usage = record(terminal?.usage) ? terminal.usage : undefined;
399
+ const details = record(usage?.output_tokens_details) ? usage.output_tokens_details : undefined;
400
+ const model = label(str(init?.model) ?? frames.flatMap(f => f.type === 'assistant' && record(f.message) ? [str(f.message.model)] : []).find(Boolean));
401
+ const version = label(str(init?.claude_code_version));
402
+ const session = label(str(terminal?.session_id ?? init?.session_id));
403
+ const subtype = label(str(terminal?.subtype));
404
+ const stopReason = label(str(terminal?.stop_reason));
405
+ digest.result = {
406
+ provider: 'claude',
407
+ ...(model === undefined ? {} : { model }),
408
+ ...(version === undefined ? {} : { claude_code_version: version }),
409
+ ...(session === undefined ? {} : { session_id: session }),
410
+ ...(subtype === undefined ? {} : { subtype }),
411
+ ...(typeof terminal?.is_error === 'boolean' ? { is_error: terminal.is_error } : {}),
412
+ ...(stopReason === undefined ? {} : { stop_reason: stopReason }),
413
+ ...(num(terminal?.num_turns) === undefined ? {} : { num_turns: num(terminal?.num_turns) }),
414
+ ...(num(terminal?.duration_ms) === undefined ? {} : { duration_ms: num(terminal?.duration_ms) }),
415
+ ...(num(terminal?.duration_api_ms) === undefined ? {} : { duration_api_ms: num(terminal?.duration_api_ms) }),
416
+ ...(num(terminal?.total_cost_usd) === undefined ? {} : { total_cost_usd: num(terminal?.total_cost_usd) }),
417
+ ...(usage === undefined ? {} : { usage: {
418
+ ...(num(usage.input_tokens) === undefined ? {} : { input: num(usage.input_tokens) }),
419
+ ...(num(usage.output_tokens) === undefined ? {} : { output: num(usage.output_tokens) }),
420
+ ...(num(usage.cache_read_input_tokens) === undefined ? {} : { cache_read: num(usage.cache_read_input_tokens) }),
421
+ ...(num(usage.cache_creation_input_tokens) === undefined ? {} : { cache_creation: num(usage.cache_creation_input_tokens) }),
422
+ ...(num(details?.thinking_tokens) === undefined ? {} : { thinking: num(details?.thinking_tokens) }),
423
+ } }),
424
+ ...(Array.isArray(terminal?.permission_denials) ? { permission_denials: terminal.permission_denials.length } : {}),
425
+ };
426
+
427
+ const calls: Array<TranscriptToolCall & { id?: string }> = [];
428
+ const byId = new Map<string, TranscriptToolCall>();
429
+ let lastToolError: string | undefined;
430
+ for (const frame of frames) {
431
+ const content = record(frame.message) && Array.isArray(frame.message.content) ? frame.message.content : [];
432
+ for (const block of content) {
433
+ if (!record(block)) continue;
434
+ if (frame.type === 'assistant' && block.type === 'tool_use') {
435
+ const excerpt = redact(block.input === undefined ? '' : JSON.stringify(block.input));
436
+ const call: TranscriptToolCall & { id?: string } = {
437
+ seq: calls.length + 1, name: label(str(block.name)) ?? '?',
438
+ input_excerpt: excerpt.length > TOOL_INPUT_EXCERPT_MAX_CHARS ? `${excerpt.slice(0, TOOL_INPUT_EXCERPT_MAX_CHARS - 1)}…` : excerpt,
439
+ };
440
+ calls.push(call);
441
+ if (typeof block.id === 'string') byId.set(block.id, call);
442
+ } else if (frame.type === 'user' && block.type === 'tool_result') {
443
+ const call = typeof block.tool_use_id === 'string' ? byId.get(block.tool_use_id) : undefined;
444
+ const text = typeof block.content === 'string' ? block.content : JSON.stringify(block.content ?? '');
445
+ if (call !== undefined) {
446
+ call.result_bytes = Buffer.byteLength(text, 'utf8');
447
+ if (block.is_error === true) call.is_error = true;
448
+ }
449
+ if (block.is_error === true) lastToolError = text;
450
+ }
451
+ }
452
+ }
453
+ if (calls.length > 0) {
454
+ const counts = new Map<string, { name: string; calls: number; errors: number }>();
455
+ for (const call of calls) {
456
+ const entry = counts.get(call.name) ?? { name: call.name, calls: 0, errors: 0 };
457
+ entry.calls += 1;
458
+ if (call.is_error === true) entry.errors += 1;
459
+ counts.set(call.name, entry);
460
+ }
461
+ const last = calls.slice(-TOOL_LAST_CALLS_MAX).map(({ id: _id, ...call }) => call);
462
+ digest.tools = {
463
+ counts: [...counts.values()].sort((a, b) => b.calls - a.calls).slice(0, TOOL_COUNTS_MAX),
464
+ last_calls: last, total_calls: calls.length, shown_calls: last.length, complete: true,
465
+ };
466
+ }
467
+ if (typeof terminal?.result === 'string') {
468
+ const text = bounded(terminal.result, FINAL_TEXT_MAX_BYTES);
469
+ digest.final_text = text.text;
470
+ if (text.truncated) digest.final_text_truncated = true;
471
+ }
472
+ if (outcome.exit_code !== 0) {
473
+ const failedResult = terminal !== undefined
474
+ && (terminal.is_error === true || (typeof terminal.subtype === 'string' && terminal.subtype !== 'success'));
475
+ if (failedResult) {
476
+ digest.failure = failure('result', typeof terminal.result === 'string' ? terminal.result : JSON.stringify(terminal), bounded);
477
+ } else if (lastToolError !== undefined) {
478
+ digest.failure = failure('tool_result', lastToolError, bounded);
479
+ } else if (outcome.stderr_tail.length > 0) {
480
+ digest.failure = failure('stderr', stderrExcerpt(outcome.stderr_tail, redact), bounded);
481
+ }
482
+ }
483
+ return digest;
484
+ }
485
+
486
+ // Codex: only `turn.completed` and `item.completed{agent_message}` are
487
+ // consumed today; tool activity is counted by item type until a fixture
488
+ // pins the frame shapes (plan U3).
489
+ const terminal = [...frames].reverse().find(f => f.type === 'turn.completed');
490
+ const usage = record(terminal?.usage) ? terminal.usage : undefined;
491
+ digest.result = {
492
+ provider: 'codex',
493
+ ...(usage === undefined ? {} : { usage: {
494
+ ...(num(usage.input_tokens) === undefined ? {} : { input: num(usage.input_tokens) }),
495
+ ...(num(usage.output_tokens) === undefined ? {} : { output: num(usage.output_tokens) }),
496
+ ...(num(usage.cached_input_tokens) === undefined ? {} : { cache_read: num(usage.cached_input_tokens) }),
497
+ } }),
498
+ };
499
+ const counts = new Map<string, { name: string; calls: number; errors: number }>();
500
+ const texts: string[] = [];
501
+ for (const frame of frames) {
502
+ if (frame.type !== 'item.completed' || !record(frame.item)) continue;
503
+ const type = label(str(frame.item.type)) ?? '?';
504
+ if (type === 'agent_message') { if (typeof frame.item.text === 'string') texts.push(frame.item.text); continue; }
505
+ const entry = counts.get(type) ?? { name: type, calls: 0, errors: 0 };
506
+ entry.calls += 1;
507
+ counts.set(type, entry);
508
+ }
509
+ if (counts.size > 0) {
510
+ const total = [...counts.values()].reduce((sum, entry) => sum + entry.calls, 0);
511
+ digest.tools = {
512
+ counts: [...counts.values()].sort((a, b) => b.calls - a.calls).slice(0, TOOL_COUNTS_MAX),
513
+ last_calls: [], total_calls: total, shown_calls: 0, complete: false,
514
+ };
515
+ }
516
+ if (texts.length > 0) {
517
+ const text = bounded(texts.join('\n'), FINAL_TEXT_MAX_BYTES);
518
+ digest.final_text = text.text;
519
+ if (text.truncated) digest.final_text_truncated = true;
520
+ }
521
+ if (outcome.exit_code !== 0 && outcome.stderr_tail.length > 0) {
522
+ digest.failure = failure('stderr', stderrExcerpt(outcome.stderr_tail, redact), bounded);
523
+ }
524
+ return digest;
525
+ }
526
+
527
+ /**
528
+ * Redact the whole stderr tail *before* cutting it. Cutting first splits a
529
+ * secret that straddles the boundary, and neither `replaceAll` on a known
530
+ * value nor a token-prefix pattern matches half a secret — so the fragment
531
+ * reached the journal. The decoder-refusal path already ordered it this way.
532
+ */
533
+ function stderrExcerpt(stderrTail: string, redact: Redactor): string {
534
+ return utf8Tail(redact(stderrTail), FAILURE_EXCERPT_MAX_BYTES);
535
+ }
536
+
537
+ /**
538
+ * Provider-supplied identifiers are copied into `result` verbatim and no
539
+ * reduction step in `boundTranscriptDigest` trims them, so an oversized one
540
+ * (a 20 KiB `model`, say) would push the digest past the kernel's
541
+ * `trajectory_tail` bound and fail the completion outright. Bound them here.
542
+ */
543
+ function label(value: string | undefined): string | undefined {
544
+ return value === undefined ? undefined : boundedText(value, DIGEST_LABEL_MAX_BYTES).text;
545
+ }
546
+
547
+ function failure(
548
+ kind: 'result' | 'tool_result' | 'stderr',
549
+ text: string,
550
+ bounded: (text: string, max: number) => { text: string; truncated: boolean },
551
+ ): NonNullable<TranscriptDigest['failure']> {
552
+ const excerpt = bounded(text, FAILURE_EXCERPT_MAX_BYTES);
553
+ return { kind, excerpt: excerpt.text, ...(excerpt.truncated ? { truncated: true } : {}) };
554
+ }
555
+
556
+ /** Serialized size of the digest as it will be journaled. */
557
+ export function digestBytes(digest: TranscriptDigest): number {
558
+ return Buffer.byteLength(JSON.stringify(digest), 'utf8');
559
+ }
560
+
561
+ /**
562
+ * Shrink the digest until it serializes to at most `TRANSCRIPT_DIGEST_MAX_BYTES`,
563
+ * in a fixed order: recent tool calls, artifact paths, final text, failure
564
+ * excerpt, tool counts. Each cut leaves its mark — `shown_calls` under
565
+ * `total_calls`, `artifacts.count` above `paths.length`, `final_text_truncated`,
566
+ * `failure.truncated` — so a reader knows the digest is not the whole account.
567
+ */
568
+ export function boundTranscriptDigest(digest: TranscriptDigest): TranscriptDigest {
569
+ let out: TranscriptDigest = digest;
570
+ const steps: Array<(d: TranscriptDigest) => TranscriptDigest> = [
571
+ d => keepLastCalls(d, 10),
572
+ d => keepArtifactPaths(d, 10),
573
+ d => cutFinalText(d, 512),
574
+ d => cutFailure(d, 256),
575
+ d => keepLastCalls(d, 0),
576
+ d => keepArtifactPaths(d, 0),
577
+ d => d.tools === undefined ? d : { ...d, tools: { ...d.tools, counts: d.tools.counts.slice(0, 8) } },
578
+ d => cutFinalText(d, 0),
579
+ d => cutFailure(d, 0),
580
+ ];
581
+ for (const step of steps) {
582
+ if (digestBytes(out) <= TRANSCRIPT_DIGEST_MAX_BYTES) return out;
583
+ out = step(out);
584
+ }
585
+ if (digestBytes(out) <= TRANSCRIPT_DIGEST_MAX_BYTES) return out;
586
+ // Last resort. Every step above trims a string this module produced; none of
587
+ // them touches a provider-supplied identifier, so a pathological `model`,
588
+ // `session_id` or set of tool names can still hold the digest over its cap —
589
+ // and the kernel refuses the whole completion when `trajectory_tail` is
590
+ // oversized (kernel/relayflowd/src/server.rs, step.complete). Falling back to
591
+ // fixed-size fields keeps the attempt recorded.
592
+ return coreDigest(out);
593
+ }
594
+
595
+ /**
596
+ * The digest reduced to fields whose size this module controls: numbers, the
597
+ * provider tag, a bounded path and a fixed-width hash. Nothing a provider can
598
+ * make arbitrarily long survives, so the result is bounded by construction.
599
+ */
600
+ function coreDigest(d: TranscriptDigest): TranscriptDigest {
601
+ const core: TranscriptDigest = { core_only: true };
602
+ if (d.attempt !== undefined) core.attempt = d.attempt;
603
+ if (d.exit_code !== undefined) core.exit_code = d.exit_code;
604
+ if (d.file !== undefined) {
605
+ core.file = { ...d.file, path: utf8Head(d.file.path, DIGEST_LABEL_MAX_BYTES) };
606
+ }
607
+ if (d.result !== undefined) {
608
+ const { provider, usage, is_error, num_turns, duration_ms, duration_api_ms, total_cost_usd, permission_denials } = d.result;
609
+ core.result = {
610
+ provider,
611
+ ...(is_error === undefined ? {} : { is_error }),
612
+ ...(num_turns === undefined ? {} : { num_turns }),
613
+ ...(duration_ms === undefined ? {} : { duration_ms }),
614
+ ...(duration_api_ms === undefined ? {} : { duration_api_ms }),
615
+ ...(total_cost_usd === undefined ? {} : { total_cost_usd }),
616
+ ...(permission_denials === undefined ? {} : { permission_denials }),
617
+ ...(usage === undefined ? {} : { usage }),
618
+ };
619
+ }
620
+ if (d.tools !== undefined) {
621
+ core.tools = {
622
+ counts: [], last_calls: [], total_calls: d.tools.total_calls, shown_calls: 0, complete: d.tools.complete,
623
+ };
624
+ }
625
+ if (d.artifacts !== undefined) core.artifacts = { count: d.artifacts.count, paths: [] };
626
+ if (d.final_text !== undefined || d.final_text_truncated === true) core.final_text_truncated = true;
627
+ if (d.failure !== undefined) core.failure = { kind: d.failure.kind, excerpt: '', truncated: true };
628
+ return core;
629
+ }
630
+
631
+ function keepLastCalls(d: TranscriptDigest, n: number): TranscriptDigest {
632
+ if (d.tools === undefined || d.tools.last_calls.length <= n) return d;
633
+ const last = n === 0 ? [] : d.tools.last_calls.slice(-n);
634
+ return { ...d, tools: { ...d.tools, last_calls: last, shown_calls: last.length } };
635
+ }
636
+
637
+ function keepArtifactPaths(d: TranscriptDigest, n: number): TranscriptDigest {
638
+ if (d.artifacts === undefined || d.artifacts.paths.length <= n) return d;
639
+ return { ...d, artifacts: { count: d.artifacts.count, paths: d.artifacts.paths.slice(0, n) } };
640
+ }
641
+
642
+ function cutFinalText(d: TranscriptDigest, maxBytes: number): TranscriptDigest {
643
+ if (d.final_text === undefined || Buffer.byteLength(d.final_text, 'utf8') <= maxBytes) return d;
644
+ if (maxBytes === 0) { const { final_text: _t, ...rest } = d; return { ...rest, final_text_truncated: true }; }
645
+ return { ...d, final_text: boundedText(d.final_text, maxBytes).text, final_text_truncated: true };
646
+ }
647
+
648
+ function cutFailure(d: TranscriptDigest, maxBytes: number): TranscriptDigest {
649
+ if (d.failure === undefined || Buffer.byteLength(d.failure.excerpt, 'utf8') <= maxBytes) return d;
650
+ return { ...d, failure: { ...d.failure, excerpt: boundedText(d.failure.excerpt, maxBytes).text, truncated: true } };
651
+ }
package/src/cli/run.ts CHANGED
@@ -808,7 +808,8 @@ function renderStepEvidence(details: StepFailedDetails): string {
808
808
  + '.'
809
809
  + (details.detail === undefined ? '' : `\nDetail: ${details.detail}`)
810
810
  + (details.stdoutTail ? `\nStdout (last 1,024 bytes):\n${details.stdoutTail}` : '')
811
- + (details.stderrTail ? `\nStderr (last 1,024 bytes):\n${details.stderrTail}` : '');
811
+ + (details.stderrTail ? `\nStderr (last 1,024 bytes):\n${details.stderrTail}` : '')
812
+ + (details.transcriptPath === undefined ? '' : `\nTranscript: ${details.transcriptPath}`);
812
813
  }
813
814
 
814
815
  function throwIfCanceled(signal: AbortSignal | undefined, stepId: string): void {