orcareplay 0.1.0

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 (109) hide show
  1. package/dist/api.d.ts +127 -0
  2. package/dist/api.d.ts.map +1 -0
  3. package/dist/api.js +159 -0
  4. package/dist/api.js.map +1 -0
  5. package/dist/args.d.ts +24 -0
  6. package/dist/args.d.ts.map +1 -0
  7. package/dist/args.js +124 -0
  8. package/dist/args.js.map +1 -0
  9. package/dist/cli.d.ts +3 -0
  10. package/dist/cli.d.ts.map +1 -0
  11. package/dist/cli.js +4 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/commands/compare.d.ts +27 -0
  14. package/dist/commands/compare.d.ts.map +1 -0
  15. package/dist/commands/compare.js +182 -0
  16. package/dist/commands/compare.js.map +1 -0
  17. package/dist/commands/doctor.d.ts +24 -0
  18. package/dist/commands/doctor.d.ts.map +1 -0
  19. package/dist/commands/doctor.js +358 -0
  20. package/dist/commands/doctor.js.map +1 -0
  21. package/dist/commands/gc.d.ts +24 -0
  22. package/dist/commands/gc.d.ts.map +1 -0
  23. package/dist/commands/gc.js +326 -0
  24. package/dist/commands/gc.js.map +1 -0
  25. package/dist/commands/inspect.d.ts +24 -0
  26. package/dist/commands/inspect.d.ts.map +1 -0
  27. package/dist/commands/inspect.js +262 -0
  28. package/dist/commands/inspect.js.map +1 -0
  29. package/dist/commands/record.d.ts +17 -0
  30. package/dist/commands/record.d.ts.map +1 -0
  31. package/dist/commands/record.js +497 -0
  32. package/dist/commands/record.js.map +1 -0
  33. package/dist/commands/replay.d.ts +43 -0
  34. package/dist/commands/replay.d.ts.map +1 -0
  35. package/dist/commands/replay.js +800 -0
  36. package/dist/commands/replay.js.map +1 -0
  37. package/dist/commands/scrub.d.ts +34 -0
  38. package/dist/commands/scrub.d.ts.map +1 -0
  39. package/dist/commands/scrub.js +452 -0
  40. package/dist/commands/scrub.js.map +1 -0
  41. package/dist/commands/setup.d.ts +23 -0
  42. package/dist/commands/setup.d.ts.map +1 -0
  43. package/dist/commands/setup.js +177 -0
  44. package/dist/commands/setup.js.map +1 -0
  45. package/dist/config.d.ts +80 -0
  46. package/dist/config.d.ts.map +1 -0
  47. package/dist/config.js +117 -0
  48. package/dist/config.js.map +1 -0
  49. package/dist/exchange-events.d.ts +72 -0
  50. package/dist/exchange-events.d.ts.map +1 -0
  51. package/dist/exchange-events.js +170 -0
  52. package/dist/exchange-events.js.map +1 -0
  53. package/dist/flags.d.ts +10 -0
  54. package/dist/flags.d.ts.map +1 -0
  55. package/dist/flags.js +83 -0
  56. package/dist/flags.js.map +1 -0
  57. package/dist/fs-events.d.ts +19 -0
  58. package/dist/fs-events.d.ts.map +1 -0
  59. package/dist/fs-events.js +53 -0
  60. package/dist/fs-events.js.map +1 -0
  61. package/dist/index.d.ts +18 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +18 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/main.d.ts +2 -0
  66. package/dist/main.d.ts.map +1 -0
  67. package/dist/main.js +282 -0
  68. package/dist/main.js.map +1 -0
  69. package/dist/mcp-server.d.ts +44 -0
  70. package/dist/mcp-server.d.ts.map +1 -0
  71. package/dist/mcp-server.js +245 -0
  72. package/dist/mcp-server.js.map +1 -0
  73. package/dist/mcp.d.ts +111 -0
  74. package/dist/mcp.d.ts.map +1 -0
  75. package/dist/mcp.js +221 -0
  76. package/dist/mcp.js.map +1 -0
  77. package/dist/out.d.ts +69 -0
  78. package/dist/out.d.ts.map +1 -0
  79. package/dist/out.js +138 -0
  80. package/dist/out.js.map +1 -0
  81. package/dist/rasterize.d.ts +62 -0
  82. package/dist/rasterize.d.ts.map +1 -0
  83. package/dist/rasterize.js +154 -0
  84. package/dist/rasterize.js.map +1 -0
  85. package/dist/serial.d.ts +19 -0
  86. package/dist/serial.d.ts.map +1 -0
  87. package/dist/serial.js +34 -0
  88. package/dist/serial.js.map +1 -0
  89. package/dist/share-card.d.ts +73 -0
  90. package/dist/share-card.d.ts.map +1 -0
  91. package/dist/share-card.js +413 -0
  92. package/dist/share-card.js.map +1 -0
  93. package/dist/snapshot.d.ts +30 -0
  94. package/dist/snapshot.d.ts.map +1 -0
  95. package/dist/snapshot.js +43 -0
  96. package/dist/snapshot.js.map +1 -0
  97. package/dist/tls-capture.d.ts +57 -0
  98. package/dist/tls-capture.d.ts.map +1 -0
  99. package/dist/tls-capture.js +198 -0
  100. package/dist/tls-capture.js.map +1 -0
  101. package/dist/upstream.d.ts +20 -0
  102. package/dist/upstream.d.ts.map +1 -0
  103. package/dist/upstream.js +31 -0
  104. package/dist/upstream.js.map +1 -0
  105. package/dist/version.d.ts +2 -0
  106. package/dist/version.d.ts.map +1 -0
  107. package/dist/version.js +2 -0
  108. package/dist/version.js.map +1 -0
  109. package/package.json +49 -0
@@ -0,0 +1,800 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import { enclosingDelegation, TraceReader, deriveCheckpoints, resolveRunSelector, ensureRunsDir, snapToCheckpoint, TraceWriter, } from '@orcareplay/core';
6
+ import { FsCapture } from '@orcareplay/fs-capture';
7
+ import { createProxy, defaultDialects } from '@orcareplay/proxy';
8
+ import { defaultAdapters, resolveLaunch } from '@orcareplay/adapters';
9
+ import { serveViewer } from '@orcareplay/viewer';
10
+ import { isBlobRef } from '@orcareplay/schema';
11
+ import { ExchangeEventDeriver, appendDerivedEvents } from '../exchange-events.js';
12
+ import { SerialQueue } from '../serial.js';
13
+ import { appendSnapshot } from '../fs-events.js';
14
+ import { drainMcpFrames, mcpForReplay, pointAtMcpConfig } from '../mcp.js';
15
+ import { setupTlsCapture, trustRunCa } from '../tls-capture.js';
16
+ import { upstreamPlan } from '../upstream.js';
17
+ import { ORCA_VERSION } from '../version.js';
18
+ /**
19
+ * Rebuild the recorded exchanges from a trace.
20
+ *
21
+ * The raw request and response bodies are the authoritative record — they are what makes exact
22
+ * replay exact — so they come back verbatim from the blob store rather than being regenerated
23
+ * from the canonical form.
24
+ */
25
+ export async function loadExchanges(reader) {
26
+ const events = await reader.events();
27
+ const requests = events.filter((e) => e.type === 'model.request');
28
+ const responses = events.filter((e) => e.type === 'model.response');
29
+ const dialects = defaultDialects();
30
+ const exchanges = [];
31
+ for (const [i, req] of requests.entries()) {
32
+ const res = responses[i];
33
+ const rawRequest = await rawBodyOf(reader, req);
34
+ const rawResponse = res ? await rawBodyOf(reader, res) : '{}';
35
+ const dialectId = String(req.attrs?.dialect ?? 'anthropic');
36
+ const dialect = dialects.find((d) => d.id === dialectId) ?? dialects[0];
37
+ exchanges.push({
38
+ seq: req.seq,
39
+ dialect: dialectId,
40
+ path: String(req.attrs?.path ??
41
+ (dialectId === 'anthropic'
42
+ ? '/v1/messages'
43
+ : dialectId === 'codex'
44
+ ? '/backend-api/codex/responses'
45
+ : '/v1/chat/completions')),
46
+ rawRequest,
47
+ rawResponse,
48
+ status: Number(res?.attrs?.status ?? 200),
49
+ streamed: Boolean(res?.attrs?.streamed ?? false),
50
+ // The stored body is the provider's wire format, not the canonical form. Canonicalizing it
51
+ // with the same translator the proxy uses at replay time is what makes rung-1 matching
52
+ // possible at all — parsing it directly makes every request look like a major divergence.
53
+ canonicalRequest: dialect.toCanonicalRequest(JSON.parse(rawRequest)),
54
+ durationMs: Number(res?.attrs?.duration_ms ?? 0),
55
+ });
56
+ }
57
+ return exchanges;
58
+ }
59
+ /**
60
+ * Read a recorded body back as the exact bytes that were sent.
61
+ *
62
+ * The subtlety is the spill boundary. The writer stores `JSON.stringify(payload)` in a blob once a
63
+ * payload crosses `INLINE_PAYLOAD_LIMIT`, and every wire body is a *string* — so the same body is
64
+ * kept as itself while it fits inline and as a quoted, backslash-escaped JSON string literal once
65
+ * it spills. Reading the blob bytes straight back therefore returns an escaped copy: canonicalizing
66
+ * it yields `model: ''` and `messages: []`, every request falls to rung 4, and replay against any
67
+ * real harness matches nothing at all. Undoing the writer's encoding is what makes the two forms
68
+ * identical again.
69
+ *
70
+ * Not `resolvePayload`, which would hand back a parsed object for a body that was recorded as one;
71
+ * exact replay means the agent gets the same bytes, so anything that is not a JSON string is
72
+ * returned verbatim.
73
+ */
74
+ async function rawBodyOf(reader, event) {
75
+ const payload = event.payload;
76
+ if (isBlobRef(payload)) {
77
+ const text = new TextDecoder().decode(await reader.blob(payload));
78
+ try {
79
+ const decoded = JSON.parse(text);
80
+ return typeof decoded === 'string' ? decoded : text;
81
+ }
82
+ catch {
83
+ // Not JSON at all — a blob written by something else, or a future media type. The bytes are
84
+ // still the best answer available.
85
+ return text;
86
+ }
87
+ }
88
+ if (typeof payload === 'string')
89
+ return payload;
90
+ return JSON.stringify(payload ?? {});
91
+ }
92
+ /**
93
+ * The arguments that will make the agent ask again.
94
+ *
95
+ * A run recorded as `orca record claude -- -p "..."` carries its prompt in argv, and replaying it
96
+ * is simply a matter of passing it back. A run driven by hand carries nothing: the prompt went
97
+ * into a terminal, and argv is just `["claude"]`. Replaying that launched an agent with no reason
98
+ * to call anything, so the recorded exchanges were never requested and the replay came back empty.
99
+ *
100
+ * The prompts recorded from the harness's own transcript are what closes that gap. Only the first
101
+ * turn is driven — the harnesses take one prompt per non-interactive invocation — so a multi-turn
102
+ * conversation says how much of itself it is reproducing instead of quietly reproducing one turn
103
+ * and calling it a replay.
104
+ */
105
+ function driveArgs(adapter, ctx, out) {
106
+ const recorded = ctx.manifest.argv.slice(1);
107
+ const prompts = ctx.prompts;
108
+ if (prompts.length === 0)
109
+ return recorded;
110
+ // Whether argv already drives the run, decided by comparing it against the prompts the harness
111
+ // recorded rather than by looking for a flag. `codex exec` with the prompt on stdin has a
112
+ // non-empty argv that carries no prompt at all, so "argv is non-empty" answers the wrong
113
+ // question; "argv contains what the person actually asked" answers the right one.
114
+ if (prompts.some((prompt) => recorded.includes(prompt)))
115
+ return recorded;
116
+ const driven = adapter.driveArgs?.(prompts, recorded);
117
+ if (driven === undefined)
118
+ return recorded;
119
+ // Said before the run, not after, because it reframes every divergence that follows. A harness
120
+ // driven without a terminal does not send byte-identical requests to the ones it sent with one —
121
+ // Claude Code splices an extra reminder turn into an interactive conversation, and the calls it
122
+ // makes for itself, like naming the session, have no counterpart at all. Those show up as
123
+ // unmatched, and without this line they read as a corrupt trace rather than as the cost of
124
+ // reproducing a conversation nobody recorded the keystrokes of.
125
+ out.info('replay.driven', {
126
+ source: 'harness transcript',
127
+ turns_recorded: prompts.length,
128
+ turns_driven: 1,
129
+ note: prompts.length > 1
130
+ ? 'the harness takes one prompt per non-interactive run; later turns are not re-asked'
131
+ : 'requests the harness made for itself may not recur',
132
+ });
133
+ return driven;
134
+ }
135
+ /**
136
+ * Prompts off a `session.snapshot`.
137
+ *
138
+ * The transcript rides in the same payload, so this spills to a blob for any real session — which
139
+ * is why it takes the reader rather than reading the event alone.
140
+ */
141
+ async function readPrompts(reader, events) {
142
+ const event = events.find((e) => e.type === 'session.snapshot');
143
+ if (event === undefined)
144
+ return [];
145
+ let payload = event.payload;
146
+ try {
147
+ if (isBlobRef(payload)) {
148
+ payload = JSON.parse(new TextDecoder().decode(await reader.blob(payload)));
149
+ }
150
+ if (typeof payload === 'string')
151
+ payload = JSON.parse(payload);
152
+ }
153
+ catch {
154
+ return [];
155
+ }
156
+ if (payload === null || typeof payload !== 'object')
157
+ return [];
158
+ const prompts = payload.prompts;
159
+ return Array.isArray(prompts) ? prompts.filter((p) => typeof p === 'string') : [];
160
+ }
161
+ export async function replayCommand(args, out, cwd = process.cwd()) {
162
+ const selector = args.positionals[0] ?? 'last';
163
+ const runDir = (await resolveRunSelector(cwd, selector)).dir;
164
+ const reader = await TraceReader.open(runDir);
165
+ const manifest = reader.manifest();
166
+ const events = await reader.events();
167
+ const integrity = await reader.verifyIntegrity();
168
+ // Two different facts, and saying the wrong one costs trust in the right one. A recorder that was
169
+ // killed leaves a run with no digest at all, and telling that person their trace "changed since
170
+ // the run ended" is an accusation about a file nothing touched.
171
+ if (integrity.state === 'mismatch') {
172
+ out.warn('trace.integrity', {
173
+ expected: integrity.expected,
174
+ actual: integrity.actual,
175
+ note: 'events.jsonl changed since the run ended',
176
+ });
177
+ }
178
+ else if (integrity.state === 'unsealed') {
179
+ out.warn('trace.unsealed', {
180
+ actual: integrity.actual,
181
+ note: 'the recorder never sealed this run, so there is nothing to verify it against',
182
+ });
183
+ }
184
+ const exchanges = await loadExchanges(reader);
185
+ const prompts = await readPrompts(reader, events);
186
+ const from = args.num('from');
187
+ const model = args.str('model');
188
+ const isFork = from !== undefined || model !== undefined;
189
+ const result = isFork
190
+ ? await replayFork(args, out, {
191
+ manifest,
192
+ events,
193
+ exchanges,
194
+ runDir,
195
+ cwd,
196
+ prompts,
197
+ from,
198
+ model,
199
+ })
200
+ : await replayExact(args, out, { manifest, events, exchanges, runDir, cwd, prompts });
201
+ if (args.bool('ui')) {
202
+ // Show the run you just produced: after a fork that is the child, not the parent, because
203
+ // the child is the one carrying the outcome you asked the question about.
204
+ const target = result.forkRunId
205
+ ? (await resolveRunSelector(cwd, result.forkRunId)).dir
206
+ : runDir;
207
+ await openViewer(target, args, out);
208
+ }
209
+ return result;
210
+ }
211
+ async function openViewer(runDir, args, out) {
212
+ const server = await serveViewer({ runDir, port: args.num('port') ?? 0 });
213
+ out.phase('viewer', { url: server.url });
214
+ out.plain(' ctrl-c to stop');
215
+ await new Promise((resolve) => {
216
+ process.once('SIGINT', () => void server.close().then(resolve));
217
+ });
218
+ }
219
+ /**
220
+ * Exact replay. Every response comes from the trace and egress is blocked — if the agent reaches
221
+ * for the network here, that is a bug we want to fail on, not paper over.
222
+ *
223
+ * It also restores the filesystem the run started from, and that is not a nicety. A harness reads
224
+ * files into the conversation, so the bytes on disk end up inside the recorded request; the run
225
+ * then edits those same files. Replaying in the directory you recorded in re-reads what the
226
+ * recording itself changed, the trailing message differs, and the replay halts at rung 4 —
227
+ * correctly, but uselessly, because nothing about the recording was wrong. Restoring first is
228
+ * what makes "exact" mean anything, and it has the second virtue of leaving your checkout alone
229
+ * rather than letting the agent edit it a second time.
230
+ */
231
+ async function replayExact(args, out, ctx) {
232
+ const divergences = [];
233
+ const unmatched = [];
234
+ const workspace = await replayWorkspace(args, out, ctx);
235
+ const trace = await openReplayTrace(args, ctx, workspace.dir);
236
+ // Serial for the same reason the recorder's is: the callbacks fire from the proxy's request
237
+ // handler, and two overlapping appends would interleave lines in events.jsonl.
238
+ const writes = new SerialQueue();
239
+ const plan = await upstreamPlan(args);
240
+ // A subscription-backed harness does not use the ordinary base URL, so exact replay needs the
241
+ // same per-run CA and HTTPS proxy as recording. The proxy's TLS hook then answers Codex's model
242
+ // request from the trace before it can open an origin connection.
243
+ const tls = await setupTlsCapture({
244
+ args,
245
+ out,
246
+ writer: trace,
247
+ runDir: ctx.runDir,
248
+ writes,
249
+ turn: () => 0,
250
+ });
251
+ const proxy = await createProxy({
252
+ mode: 'replay',
253
+ exchanges: ctx.exchanges,
254
+ loose: args.bool('loose'),
255
+ upstream: plan.upstream,
256
+ upstreamHeaders: plan.headers,
257
+ ...tls.proxyOptions,
258
+ onDivergence: (d) => {
259
+ divergences.push(d);
260
+ if (!trace)
261
+ return;
262
+ writes.push(async () => {
263
+ await trace.append({
264
+ type: 'divergence',
265
+ actor: 'orca',
266
+ // Turn 0 for everything in this trace, and deliberately not a running count. An exact
267
+ // match produces no callback, so a counter here would number the third divergence as
268
+ // turn 1 and claim a position in the conversation that it does not have. `source_seq`
269
+ // is the honest coordinate: it points straight at the parent's own event.
270
+ turn: 0,
271
+ attrs: { level: d.level, rung: d.rung, detail: d.detail, source_seq: d.seq },
272
+ });
273
+ });
274
+ },
275
+ // Printed as it happens rather than tallied at the end. A halted replay stops the agent, so
276
+ // the count in `replay.done` arrives after the operator has already seen the run die — and a
277
+ // bare `unmatched=1` with no reason is indistinguishable from a bug in orca itself.
278
+ //
279
+ // The two directories are on the line because a distance in the hundreds of thousands is true
280
+ // and unactionable. Harnesses put absolute paths in their tool calls, so a replay running
281
+ // anywhere but the recording's own directory gets a permission refusal where the recording has
282
+ // file contents — by far the most common cause of a halt, and invisible from the number alone.
283
+ onUnmatched: (u) => {
284
+ unmatched.push(u);
285
+ // A halt inside a delegation is not a corrupt trace and not a bug, and looks like both. The
286
+ // harness writes the delegate's prompt itself, fresh on every run, so the request really is
287
+ // a different question — which is exactly what the matcher refuses to serve from a
288
+ // recording. Without this the operator sees `distance 54` and goes looking for the fault.
289
+ const delegation = enclosingDelegation(ctx.events, u.seq);
290
+ out.warn('replay.unmatched', {
291
+ seq: u.seq,
292
+ index: u.index,
293
+ reason: u.reason,
294
+ ...(delegation === undefined
295
+ ? {}
296
+ : {
297
+ inside: `${delegation.subagent} delegated at seq ${delegation.seq}`,
298
+ why: 'the harness writes a delegate prompt of its own each run, so this request is a different question rather than a drifted one',
299
+ }),
300
+ recorded_in: ctx.manifest.cwd,
301
+ replayed_in: workspace.dir,
302
+ next: workspace.dir === ctx.manifest.cwd
303
+ ? 'orca replay <run> --loose'
304
+ : `cd ${ctx.manifest.cwd} && orca replay <run> --in-place # or --loose to continue live`,
305
+ });
306
+ if (!trace)
307
+ return;
308
+ // `error`, not `divergence`: nothing was served and the run is over, so calling it an
309
+ // inexact match would put a rung on a ladder the request never climbed. It is also the one
310
+ // finding here that exists nowhere else — a matched exchange is already in the parent, but
311
+ // "the agent asked for something this recording cannot answer" is new, and until now it
312
+ // lived only in the operator's scrollback.
313
+ writes.push(async () => {
314
+ await trace.append({
315
+ type: 'error',
316
+ actor: 'orca',
317
+ turn: 0,
318
+ attrs: {
319
+ rule: 'replay_unmatched',
320
+ rung: 4,
321
+ reason: u.reason,
322
+ index: u.index,
323
+ source_seq: u.seq,
324
+ recorded_in: ctx.manifest.cwd,
325
+ replayed_in: workspace.dir,
326
+ },
327
+ });
328
+ });
329
+ },
330
+ });
331
+ await trace?.append({
332
+ type: 'run.start',
333
+ actor: 'orca',
334
+ turn: 0,
335
+ attrs: {
336
+ adapter: ctx.manifest.adapter.id,
337
+ cwd: workspace.dir,
338
+ proxy: proxy.url,
339
+ mode: 'replay',
340
+ // Also on the manifest, which is what an out-of-process reader sees first. Here as well
341
+ // because a trace read on its own should say what it is a replay of.
342
+ parent_run: ctx.manifest.run_id,
343
+ exchanges: ctx.exchanges.length,
344
+ },
345
+ });
346
+ out.phase('replaying', {
347
+ run: ctx.manifest.run_id,
348
+ exchanges: ctx.exchanges.length,
349
+ egress: 'blocked',
350
+ proxy: proxy.url,
351
+ cwd: workspace.dir,
352
+ });
353
+ // The agent runs live for everything that is not a model call, MCP included. Without a config it
354
+ // talks to servers orca cannot see — or, for a harness that requires the variable, does not start
355
+ // at all, which is how a replay of a working recording exits non-zero for a reason that has
356
+ // nothing to do with the recording.
357
+ const mcp = trace === undefined
358
+ ? undefined
359
+ : await mcpForReplay(args, ctx.events, trace, out, join(ctx.runDir, 'mcp-frames.jsonl'));
360
+ const adapter = defaultAdapters().get(ctx.manifest.adapter.id);
361
+ const launch = await adapter.prepare({
362
+ runId: ctx.manifest.run_id,
363
+ cwd: workspace.dir,
364
+ proxyUrl: proxy.url,
365
+ runDir: ctx.runDir,
366
+ userArgs: driveArgs(adapter, ctx, out),
367
+ env: process.env,
368
+ });
369
+ if (proxy.tls)
370
+ await trustRunCa(trace, proxy.tls, proxy.url, launch.env, out);
371
+ if (mcp) {
372
+ pointAtMcpConfig(launch.env, mcp.configPath);
373
+ // Same as record: the path has to reach the harness the way the harness actually reads it, or
374
+ // a replay re-instruments a config nothing opens and every recorded MCP call goes unmatched.
375
+ const mcpArgs = adapter.mcpConfigArgs?.(mcp.configPath);
376
+ if (mcpArgs !== undefined)
377
+ launch.args = [...mcpArgs, ...launch.args];
378
+ }
379
+ let exitCode;
380
+ try {
381
+ exitCode = await runChild(launch.command, launch.args, { ...process.env, ...launch.env }, workspace.dir, args.bool('json'));
382
+ }
383
+ finally {
384
+ // In a finally because the whole justification for restoring over the working tree is that it
385
+ // is put back — a throw between here and there would leave someone's checkout holding a
386
+ // recorded run's files.
387
+ await workspace.release();
388
+ await tls.ca?.dispose();
389
+ }
390
+ await writes.drain();
391
+ const stats = proxy.stats();
392
+ await proxy.close();
393
+ for (const d of divergences) {
394
+ out.warn('divergence', { seq: d.seq, level: d.level, detail: d.detail });
395
+ }
396
+ // A halted replay is a failed replay even if the harness chose to exit 0 on the error. The exit
397
+ // code is what a script reads, so it has to reflect what happened rather than what the agent
398
+ // decided to do about it — and the trace records the same verdict for the same reason.
399
+ const verdict = exitCode === 0 && unmatched.length > 0 ? 1 : exitCode;
400
+ if (trace) {
401
+ // What the replay discovered rather than repeated: these calls really happened just now, and
402
+ // exist nowhere in the parent.
403
+ if (mcp)
404
+ await drainMcpFrames(mcp, trace, () => 0, 0);
405
+ await trace.append({
406
+ type: 'run.end',
407
+ actor: 'orca',
408
+ turn: 0,
409
+ attrs: {
410
+ exit_code: verdict,
411
+ agent_exit_code: exitCode,
412
+ matched: stats.matchedExact,
413
+ divergences: stats.divergences,
414
+ unmatched: stats.unmatched,
415
+ },
416
+ });
417
+ await trace.close(verdict);
418
+ }
419
+ out.phase('replay.done', {
420
+ // `matched=1 total=13` was the old shape, and on a healthy replay of a real harness it read as
421
+ // a failure: rung 1 is only reachable when nothing in the request was redacted, so a run whose
422
+ // every request was served from disk still reported one match. What someone wants to know here
423
+ // is how much of the recording was reused, and how much of that reuse was exact.
424
+ reused: `${stats.matchedExact + stats.matchedInexact}/${ctx.exchanges.length}`,
425
+ exact: stats.matchedExact,
426
+ divergences: stats.divergences,
427
+ unmatched: stats.unmatched,
428
+ exit: exitCode,
429
+ // Omitted entirely under --no-trace: `Output` drops undefined fields, so the line stays the
430
+ // shape it has always been for anyone who opted out.
431
+ trace: trace?.runId,
432
+ });
433
+ return {
434
+ runId: ctx.manifest.run_id,
435
+ mode: 'exact',
436
+ ...(trace === undefined ? {} : { traceRunId: trace.runId }),
437
+ matchedExact: stats.matchedExact,
438
+ divergences: stats.divergences,
439
+ unmatched: stats.unmatched,
440
+ liveCalls: stats.liveCalls,
441
+ exitCode: verdict,
442
+ };
443
+ }
444
+ /**
445
+ * The run an exact replay writes about itself, or nothing under `--no-trace`.
446
+ *
447
+ * Spec §4 says every inexact match is an event in the trace, and until now the exact path had no
448
+ * trace to put one in: divergences were printed and discarded with the scrollback. They cannot go
449
+ * into the run being replayed — it is append-only and its manifest carries a digest over
450
+ * events.jsonl, so a single line appended would invalidate every verification anyone had done of
451
+ * it — so the replay becomes a run of its own, pointing back at its subject through `parent_run`.
452
+ * That one field is what `orca list`, `orca show` and `orca gc` already read, which is why it is
453
+ * on the manifest and not only in an event: gc uses it to refuse to delete a run something else
454
+ * still points at.
455
+ *
456
+ * There is no `fork_point`. An exact replay does not branch anywhere, and a fabricated checkpoint
457
+ * would be a number `orca list` prints as though someone had chosen it.
458
+ *
459
+ * What it deliberately does NOT record is the exchanges it served. Every one of them was read out
460
+ * of the parent's own events.jsonl and handed back byte for byte, so copying them here would
461
+ * duplicate the trace's single largest cost — the conversation bodies, blobs and all — to store a
462
+ * second copy that is identical by construction, and would make `orca gc` report a store twice the
463
+ * size for one recording's worth of content. An unmatched request is the opposite case: it is
464
+ * something the agent asked that the recording never contained, so it exists nowhere else and is
465
+ * written as an `error` event above. The rule is that this trace holds what replaying *discovered*,
466
+ * and points at the parent for what replaying merely repeated.
467
+ */
468
+ async function openReplayTrace(args, ctx, cwd) {
469
+ if (!args.bool('trace', true))
470
+ return undefined;
471
+ const dir = await ensureRunsDir(ctx.cwd);
472
+ return TraceWriter.create(dir, {
473
+ adapter: ctx.manifest.adapter,
474
+ argv: ctx.manifest.argv,
475
+ // Where the replay actually ran, which is not always where the recording did: `--worktree`
476
+ // puts it in a scratch copy, and `orca gc` reads exactly this to decide whether the directory
477
+ // is one of ours to reclaim.
478
+ cwd,
479
+ orcaVersion: ORCA_VERSION,
480
+ parentRun: ctx.manifest.run_id,
481
+ });
482
+ }
483
+ /**
484
+ * Fork replay. Replay deterministically to a checkpoint, materialize the filesystem as it was at
485
+ * that moment, then let the agent continue live — optionally on a different model.
486
+ */
487
+ async function replayFork(args, out, ctx) {
488
+ const checkpoints = deriveCheckpoints(ctx.events);
489
+ if (checkpoints.length === 0) {
490
+ throw new Error('this run has no checkpoints, so there is nothing to fork from\n' +
491
+ ' checkpoints need a filesystem snapshot; was the run recorded with --no-fs?');
492
+ }
493
+ const target = ctx.from ?? checkpoints[checkpoints.length - 1].seq;
494
+ const { checkpoint, snapped } = snapToCheckpoint(checkpoints, target);
495
+ if (snapped) {
496
+ // Saying so matters: silently forking from different state than the user asked for is the
497
+ // worst failure mode this tool has.
498
+ out.warn('fork.snapped', {
499
+ requested: target,
500
+ using: checkpoint.seq,
501
+ why: 'nearest preceding checkpoint',
502
+ });
503
+ }
504
+ // Exchanges are indexed by position, so the fork point in exchange terms is how many model
505
+ // requests happened at or before the checkpoint.
506
+ const forkAt = ctx.exchanges.filter((e) => e.seq <= checkpoint.seq).length;
507
+ const worktree = await mkdtemp(join(tmpdir(), `orca-${checkpoint.seq}-`));
508
+ if (checkpoint.fsTree) {
509
+ // Restore from the ORIGINAL run's shadow store: that is the only place the tree object
510
+ // exists. Pointing a fresh store at the tree id fails to unpack it, which is exactly the
511
+ // silent-wrong-state failure the checkpoint machinery is meant to prevent.
512
+ const fs = await FsCapture.start({ runDir: ctx.runDir, cwd: ctx.cwd });
513
+ await fs.restore(checkpoint.fsTree, worktree);
514
+ }
515
+ const dir = await ensureRunsDir(ctx.cwd);
516
+ const writer = await TraceWriter.create(dir, {
517
+ adapter: ctx.manifest.adapter,
518
+ argv: ctx.manifest.argv,
519
+ cwd: worktree,
520
+ orcaVersion: ORCA_VERSION,
521
+ // Also in the manifest, not only the fork event: the manifest is what an out-of-process
522
+ // reader sees first, and `orca gc` uses it to decide a parent run may not be deleted.
523
+ parentRun: ctx.manifest.run_id,
524
+ forkPoint: checkpoint.seq,
525
+ ...(ctx.model === undefined ? {} : { forkModel: ctx.model }),
526
+ });
527
+ const deriver = new ExchangeEventDeriver();
528
+ let turn = 0;
529
+ // When each turn began, so an MCP frame drained after the agent exits is attributed to the turn
530
+ // it happened during rather than to whichever turn happened to be last.
531
+ const turnStartedAt = [];
532
+ const turnAtFork = (at) => {
533
+ let found = 0;
534
+ for (const mark of turnStartedAt) {
535
+ if (mark.at > at)
536
+ break;
537
+ found = mark.turn;
538
+ }
539
+ return found;
540
+ };
541
+ const writes = new SerialQueue();
542
+ /**
543
+ * The fork's own filesystem capture, over its worktree.
544
+ *
545
+ * A fork had none, so its trace carried no `fs.snapshot` — and a checkpoint is derived from a
546
+ * snapshot (spec §3), so a fork had no checkpoints and could not itself be forked. `orca compare
547
+ * last` immediately after a fork failed with "this run has no checkpoints", because `last` had
548
+ * resolved to the fork. The tool is pitched on iterative exploration; a branch you cannot branch
549
+ * again is a dead end one step in.
550
+ *
551
+ * Same posture as recording: a capture layer that will not start degrades the trace and never
552
+ * stops the run.
553
+ */
554
+ let forkFs;
555
+ if (args.bool('fs', true)) {
556
+ try {
557
+ forkFs = await FsCapture.start({ runDir: writer.runDir, cwd: worktree });
558
+ }
559
+ catch (err) {
560
+ out.warn('fs.unavailable', { reason: String(err) });
561
+ }
562
+ }
563
+ const plan = await upstreamPlan(args);
564
+ // A fork runs a real agent live, so it has exactly the same blind spot `orca record` does: a
565
+ // harness that talks to its own backend over TLS reads no base-URL variable and is invisible
566
+ // without interception. The flag was parsed here and silently discarded, which is the worse
567
+ // half — the operator believes they captured that traffic.
568
+ const tls = await setupTlsCapture({ args, out, writer, writes, turn: () => turn });
569
+ // A fork continues the run live past the checkpoint, so its MCP traffic is new and belongs in the
570
+ // fork's own trace. Without this the layer simply stopped at the fork point.
571
+ const mcp = await mcpForReplay(args, ctx.events, writer, out);
572
+ const proxy = await createProxy({
573
+ mode: 'hybrid',
574
+ forkAt,
575
+ forkModel: ctx.model,
576
+ exchanges: ctx.exchanges,
577
+ upstream: plan.upstream,
578
+ upstreamHeaders: plan.headers,
579
+ ...tls.proxyOptions,
580
+ onExchange: (exchange) => {
581
+ writes.push(async () => {
582
+ turn += 1;
583
+ turnStartedAt.push({ turn, at: Date.now() });
584
+ await appendDerivedEvents(writer, deriver, exchange, turn);
585
+ if (forkFs)
586
+ await appendSnapshot(forkFs, writer, out, turn);
587
+ });
588
+ },
589
+ onRoute: (decision) => {
590
+ writes.push(async () => {
591
+ await writer.append({
592
+ type: 'route.decision',
593
+ // On a fork the gateway is orca: it substituted the model and picked what serves it.
594
+ actor: 'gateway',
595
+ turn,
596
+ attrs: { ...decision },
597
+ });
598
+ });
599
+ },
600
+ onDivergence: (d) => {
601
+ writes.push(async () => {
602
+ await writer.append({
603
+ type: 'divergence',
604
+ actor: 'orca',
605
+ turn,
606
+ attrs: { level: d.level, rung: d.rung, detail: d.detail, source_seq: d.seq },
607
+ });
608
+ });
609
+ },
610
+ });
611
+ await writer.append({
612
+ type: 'fork',
613
+ actor: 'orca',
614
+ turn: 0,
615
+ attrs: {
616
+ parent_run: ctx.manifest.run_id,
617
+ fork_point: checkpoint.seq,
618
+ model: ctx.model ?? ctx.manifest.adapter.id,
619
+ worktree,
620
+ },
621
+ });
622
+ // The state the fork starts from, so its first checkpoint is the checkpoint it branched at
623
+ // rather than whatever the first live turn happened to leave behind.
624
+ if (forkFs)
625
+ await appendSnapshot(forkFs, writer, out, 0, { initial: true });
626
+ out.phase('forked', {
627
+ from: ctx.manifest.run_id,
628
+ at: checkpoint.seq,
629
+ run: writer.runId,
630
+ model: ctx.model ?? '(unchanged)',
631
+ worktree,
632
+ });
633
+ const adapter = defaultAdapters().get(ctx.manifest.adapter.id);
634
+ const launch = await adapter.prepare({
635
+ runId: writer.runId,
636
+ cwd: worktree,
637
+ proxyUrl: proxy.url,
638
+ runDir: writer.runDir,
639
+ userArgs: driveArgs(adapter, ctx, out),
640
+ env: process.env,
641
+ });
642
+ if (proxy.tls) {
643
+ await trustRunCa(writer, proxy.tls, proxy.url, launch.env, out);
644
+ }
645
+ if (mcp) {
646
+ pointAtMcpConfig(launch.env, mcp.configPath);
647
+ // Same as record: the path has to reach the harness the way the harness actually reads it, or
648
+ // a replay re-instruments a config nothing opens and every recorded MCP call goes unmatched.
649
+ const mcpArgs = adapter.mcpConfigArgs?.(mcp.configPath);
650
+ if (mcpArgs !== undefined)
651
+ launch.args = [...mcpArgs, ...launch.args];
652
+ }
653
+ let exitCode;
654
+ try {
655
+ exitCode = await runChild(launch.command, launch.args, { ...process.env, ...launch.env }, worktree, args.bool('json'));
656
+ }
657
+ catch (err) {
658
+ // The same two failures `orca record` had. A listening proxy keeps Node's event loop alive, so
659
+ // a throw here printed the error and then hung; and a fork that mints a certificate authority
660
+ // must not leave the private key on disk when it dies. The trace is sealed either way, because
661
+ // a fork that failed to launch is still a fork someone will want to read.
662
+ await proxy.close().catch(() => undefined);
663
+ await writes.drain().catch(() => undefined);
664
+ await tls.ca?.dispose().catch(() => undefined);
665
+ await writer
666
+ .append({ type: 'run.end', actor: 'orca', turn, attrs: { error: String(err) } })
667
+ .catch(() => undefined);
668
+ await writer.close().catch(() => undefined);
669
+ throw err;
670
+ }
671
+ await writes.drain();
672
+ if (mcp)
673
+ await drainMcpFrames(mcp, writer, turnAtFork, turn);
674
+ const stats = proxy.stats();
675
+ await writer.append({ type: 'run.end', actor: 'orca', turn, attrs: { exit_code: exitCode } });
676
+ const manifest = await writer.close(exitCode);
677
+ await proxy.close();
678
+ await tls.ca?.dispose();
679
+ out.phase('fork.done', {
680
+ run: writer.runId,
681
+ replayed: forkAt,
682
+ live: stats.liveCalls,
683
+ divergences: stats.divergences,
684
+ events: manifest.counts?.events ?? writer.seq,
685
+ exit: exitCode,
686
+ });
687
+ return {
688
+ runId: ctx.manifest.run_id,
689
+ traceRunId: writer.runId,
690
+ forkRunId: writer.runId,
691
+ worktree,
692
+ mode: 'fork',
693
+ matchedExact: stats.matchedExact,
694
+ divergences: stats.divergences,
695
+ unmatched: stats.unmatched,
696
+ liveCalls: stats.liveCalls,
697
+ exitCode,
698
+ };
699
+ }
700
+ const noRelease = async () => { };
701
+ /**
702
+ * Prepare the filesystem an exact replay needs.
703
+ *
704
+ * Two facts decide this, and they pull against each other. A harness reads files into the
705
+ * conversation, so the bytes on disk are inside the recorded request and replaying against a
706
+ * directory the recording itself edited produces a different request. And a harness writes
707
+ * *absolute* paths into its tool calls, so replaying a copy of that directory somewhere else makes
708
+ * the agent read outside its working directory — where it gets a permission refusal in place of
709
+ * the file, which diverges just as badly. Measured on a real Claude Code run: a scratch copy
710
+ * halted at the first tool result; the same trace restored at its own path replayed all six
711
+ * exchanges with nothing unmatched.
712
+ *
713
+ * So the default restores the recorded state *over the working tree*, at the path the run was
714
+ * recorded in. That is only defensible because it is reversible: the current tree is snapshotted
715
+ * into a scratch store first and put back in a `finally`, so a replay is observationally a no-op
716
+ * on your checkout, and the snapshot id is printed before anything is touched in case the process
717
+ * is killed in between.
718
+ *
719
+ * Three ways out. `--worktree` replays in a scratch copy and never touches your files, at the cost
720
+ * of the divergence above. `--in-place` uses the tree exactly as it stands, restoring nothing.
721
+ * And a replay invoked from somewhere other than the directory the run was recorded in never
722
+ * restores, because writing a recorded tree over an unrelated directory is not a thing to do by
723
+ * default.
724
+ */
725
+ async function replayWorkspace(args, out, ctx) {
726
+ if (args.bool('in-place'))
727
+ return { dir: ctx.cwd, release: noRelease };
728
+ const initial = deriveCheckpoints(ctx.events).find((c) => c.fsTree !== undefined);
729
+ if (!initial?.fsTree) {
730
+ out.warn('replay.in-place', {
731
+ why: 'this run has no filesystem snapshot to restore',
732
+ note: 'recorded with --no-fs; a file the run read may since have changed',
733
+ });
734
+ return { dir: ctx.cwd, release: noRelease };
735
+ }
736
+ const recorded = await FsCapture.start({ runDir: ctx.runDir, cwd: ctx.cwd });
737
+ if (args.bool('worktree')) {
738
+ const worktree = await mkdtemp(join(tmpdir(), `orca-replay-${ctx.manifest.run_id}-`));
739
+ await recorded.restore(initial.fsTree, worktree);
740
+ return { dir: worktree, release: noRelease };
741
+ }
742
+ if (resolve(ctx.cwd) !== resolve(ctx.manifest.cwd)) {
743
+ out.warn('replay.elsewhere', {
744
+ recorded_in: ctx.manifest.cwd,
745
+ running_in: ctx.cwd,
746
+ note: 'not restoring over a directory the run was not recorded in; use --worktree for a copy',
747
+ });
748
+ return { dir: ctx.cwd, release: noRelease };
749
+ }
750
+ // A store of its own, under the OS temp dir: the safety snapshot is scratch, and writing it into
751
+ // the trace's shadow store would leave an object in a recorded run that nothing references.
752
+ const scratch = await mkdtemp(join(tmpdir(), 'orca-safety-'));
753
+ const safety = await FsCapture.start({ runDir: scratch, cwd: ctx.cwd });
754
+ const before = await safety.snapshotTurn(0);
755
+ out.info('replay.restored', {
756
+ to: initial.fsTree,
757
+ your_tree: before.tree,
758
+ note: 'your files are restored when the replay ends',
759
+ });
760
+ await recorded.restore(initial.fsTree, ctx.cwd);
761
+ return {
762
+ dir: ctx.cwd,
763
+ release: async () => {
764
+ await safety.restore(before.tree, ctx.cwd);
765
+ // Only after the restore succeeded. This store holds the only copy of your working tree as
766
+ // it was before the replay overwrote it, so removing it on the failure path would delete the
767
+ // thing the failure means you still need — better a directory to clean up by hand than the
768
+ // one that had your uncommitted work in it.
769
+ //
770
+ // Left behind on every run until now: a whole workspace per `orca replay`, in a directory
771
+ // `orca gc` deliberately will not touch because it only reclaims scratch worktrees belonging
772
+ // to forks. Owning its lifetime here is the fix; teaching gc to delete unknown temp
773
+ // directories is how gc ends up removing someone's work.
774
+ await rm(scratch, { recursive: true, force: true });
775
+ },
776
+ };
777
+ }
778
+ /**
779
+ * Launch the agent for a replay or a fork.
780
+ *
781
+ * `quietStdout` is for `--json`: orca's stdout is the result document there, so the replayed
782
+ * agent's own output moves to stderr rather than landing in the middle of it. stdin and stderr
783
+ * stay inherited, so a harness that prompts still can.
784
+ */
785
+ async function runChild(command, argv, env, cwd = process.cwd(), quietStdout = false) {
786
+ // Same resolution as record and as detection; see resolveLaunch.
787
+ const target = await resolveLaunch(command, argv);
788
+ return new Promise((resolve, reject) => {
789
+ const child = spawn(target.file, target.args, {
790
+ env,
791
+ cwd,
792
+ shell: target.shell,
793
+ stdio: quietStdout ? ['inherit', 'pipe', 'inherit'] : 'inherit',
794
+ });
795
+ child.stdout?.pipe(process.stderr);
796
+ child.on('error', (err) => reject(new Error(`could not launch "${command}": ${String(err)}\n is it on your PATH?`)));
797
+ child.on('close', (code) => resolve(code ?? 0));
798
+ });
799
+ }
800
+ //# sourceMappingURL=replay.js.map