Skip to content

Commit ebff2c2

Browse files
authored
feat: add record diff command for replayed screen diffs (#170)
* chore: bump overrides to clear high-severity audit advisories aube audit --audit-level high (run by CI) reports 8 high advisories in transitive dev dependencies: brace-expansion (<5.0.9), js-yaml (<4.3.1), nanoid (<3.3.18), and postcss (<=8.5.17). Bump the existing overrides block to the fixed versions; each package resolves to a single version in the tree, so the bumps are mechanical. * feat: add record diff command for replayed screen diffs computeScreenHash can only say whether two screens differ. record diff replays two session event logs offline, extracts the canonical visible screens, and emits an LCS line diff (plus per-side screen hashes) as a JSON envelope and a unified-style human diff. --at-seq-a/--at-seq-b target intermediate Event Log sequences, so a session can be diffed against its own earlier state. * fix: address record diff review findings - Synthesize the valid initial blank screen for sessions whose event log is still empty (backends reject snapshot() before the first replayed event), so record diff works on freshly created sessions. - Bound the LCS DP table by total cell count (4M) in addition to the per-side line cap, keeping worst-case memory bounded. - Parse --at-seq-a/--at-seq-b with full-token number parsing so malformed values like '1.5' or '2junk' are rejected as INVALID_INPUT instead of being silently truncated. * chore: relock aube after upstream repo transfer The aube repository moved from endevco/aube to jdx/aube. GitHub attestation lookups via the old path now find nothing, so mise 2026.8.x fails 'mise install --locked' with 'Lockfile requires github-attestations provenance ... but verification was not performed', breaking every CI job at setup. Regenerate the aube lockfile entries with current mise (mise lock aube): URLs now point at jdx/aube and the unverifiable provenance pins are dropped. All artifact sha256 checksums are unchanged, so the pinned binaries are identical. * fix: address record diff round-2 review findings - Reuse the first replay when both diff selectors are identical, so 'record diff <id> <id>' on an actively printing session cannot race between two event-log reads and report spurious differences. - Replace the hard DP-table cell bound with common prefix/suffix trimming plus a non-minimal delete-then-add fallback for oversized middles: every valid screen pair now diffs successfully instead of crashing with an uncaught AssertionError. * fix: append large diff middles iteratively Spreading the fallback middle into push() exceeds V8's function-argument limit for ~125k-entry middles, throwing RangeError instead of returning the JSON envelope. Append iteratively. * fix: tighten record diff schema contract - Model diff entries as a strict discriminated union keyed by op: equal requires both row indices, delete only aRow, add only bRow. - Refine the result schema so identical is true exactly when both screen hashes are equal, identical results carry an empty diff, and non-identical results carry at least one delete or add entry. * fix: report capturedAtSeq -1 for empty-event-log diff sides The blank-screen fallback claimed capturedAtSeq 0, fabricating an event sequence that was never replayed (and that --at-seq 0 cannot target while the log is empty). Report -1, mirroring ReplayInput.targetSeq semantics for an empty log, and widen the side schema accordingly. * fix: address record diff round-6 review findings - Parse --at-seq-a/--at-seq-b with a strict integer-token parser so empty or whitespace-only values (e.g. an unset shell variable) are rejected as INVALID_INPUT instead of resolving to sequence 0. - Add an explicit phase boundary to the scrollback-demo fixture so the completion marker lands in its own PTY chunk, keeping the record diff e2e assertion deterministic regardless of PTY chunk coalescing. * fix: bound record diff row coordinates by side dimensions Diff entries index the padded visible screens, so aRow/bRow are always less than the corresponding side's rows; refine the schema to reject out-of-bounds coordinates. * fix: require record diff rows to enumerate both screens in order The diff is a complete traversal of both padded visible screens, so the schema now requires participating A and B coordinates to each enumerate 0..rows-1 exactly once in order, letting consumers reconstruct either side by filtering operations. * fix: accept every input size in diffLines Remove the per-side line cap: prefix/suffix trimming and the cell- budget fallback already bound the quadratic DP table, and all other work is linear, so no valid screen dimensions can crash record diff with an uncaught AssertionError. * fix: verify record diff hashes against reconstructed screens - The result schema now reconstructs both sides from the diff traversal and requires each declared screenHash to match the reconstruction, so the diff, hashes, and identical flag can never contradict each other after successful validation. - Reject --at-seq tokens beyond Number.MAX_SAFE_INTEGER instead of silently rounding them to a different sequence. * fix: require equal row counts for identical record diff results Equal screen hashes imply equal canonical line sequences, which have one line per padded visible row, so identical results cannot report different side dimensions. * fix: address record diff round-12 review findings - Refine the schema so a pre-event side (capturedAtSeq -1) must hash to a blank screen of its declared row count. - Replace the scrollback-demo wall-clock phase pause with an opt-in stdin handshake (--wait-input-before-complete): the record diff e2e now waits for an observed pre-completion Event Log state, releases the fixture, and diffs against that sequence, eliminating the PTY chunk-coalescing race entirely. * fix: bound record diff side dimensions in the schema Cap cols/rows at a generous ceiling so validation-time work (such as hashing the blank pre-event screen) is proportional to a schema-checked limit instead of attacker-controlled input. * fix: keep record diff dimensions unrestricted, bound only hash work Sessions may be created or resized to any positive dimensions, so the side schema must not narrow the public contract. Drop the cols/rows cap and instead skip the pre-event blank-hash equality check above a 100k-row work bound, keeping safeParse cost bounded without rejecting contract-valid results. * docs: state the record diff minimality bound explicitly The LCS is exact for any realistic screen; when the trimmed differing region exceeds the cell budget on both sides the diff degrades to a delete-then-add block. Document that public semantics boundary — identity, hashes, and reconstruction remain exact. * docs: describe the diff minimality budget by its cell product * fix: reject missing event logs before synthesizing blank screens The host creates events.jsonl at startup, so an empty replay is only authoritative when the zero-length log exists. A deleted or never- written log now fails with REPLAY_ERROR instead of fabricating an identical blank-screen result for lost recordings. * fix: skip the LCS table for one-sided diff middles An empty middle side needs no comparisons; construct the delete/add entries directly so huge one-sided middles stay linear in memory instead of allocating millions of one-element table rows.
1 parent 2bc8370 commit ebff2c2

12 files changed

Lines changed: 1904 additions & 11 deletions

File tree

docs/USAGE.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,21 @@ Use `--renderer ghostty-web`, `AGENT_TTY_RENDERER=ghostty-web`, or Home `config.
228228

229229
`ghostty-web` provides reference visual truth for reviewable artifacts; it does not promise exact pixel parity with native terminals.
230230

231+
## `record diff`
232+
233+
`computeScreenHash` (see [Screen Hash](#screen-hash)) can only say _whether_ two screens differ. Use `record diff` to see _what_ changed: it replays two recorded sessions offline from their event logs and prints an LCS line diff of the canonical visible screens.
234+
235+
```bash
236+
agent-tty record diff <session-id-a> <session-id-b> --json
237+
agent-tty record diff <session-id> <session-id> --at-seq-a 0 --json
238+
```
239+
240+
- `--at-seq-a <seq>` / `--at-seq-b <seq>`: replay each side up to an Event Log sequence (default: latest). Diffing a session against itself at an earlier sequence shows how its screen evolved.
241+
- The JSON result carries `identical`, per-side `sessionId`/`capturedAtSeq`/`cols`/`rows`/`screenHash`, and a `diff` array of `{ op: equal | delete | add, text, aRow?, bRow? }` entries over the visible screen lines (0-based rows, no trimming or normalization — the same canonical lines that `screenHash` hashes). A side with an empty event log reports the pre-event blank screen with `capturedAtSeq: -1` (no event was replayed).
242+
- Human output is a unified-style diff with `---`/`+++` headers naming each side's session, sequence, and hash prefix.
243+
- The line diff is LCS-minimal for any realistic screen. As a bounded-memory safeguard, when the differing region (after matching the common prefix and suffix) is so large that the product of its two side lengths exceeds a 4,000,000-cell budget (for example 1,000 × 4,001 lines), that region degrades to a plain delete-then-add block instead of a minimal diff — `identical`, both `screenHash` values, and full-screen reconstruction remain exact; only diff minimality is reduced.
244+
- The command works entirely offline from `events.jsonl`; sessions may be running or exited. The exit code is `0` whether or not the screens differ — automation should read `identical` from the JSON result.
245+
231246
## Isolation
232247

233248
`--home <path>` stores manifests, sockets, event logs, and artifacts under an isolated agent-tty home.

src/cli/commands/record-diff.ts

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { access } from 'node:fs/promises';
2+
3+
import type { CommandContext } from '../context.js';
4+
import type {
5+
RecordDiffResult,
6+
RecordDiffSide,
7+
} from '../../protocol/messages.js';
8+
9+
import { emitSuccess } from '../output.js';
10+
import { CliError } from '../errors.js';
11+
import { ERROR_CODES, makeCliError } from '../../protocol/errors.js';
12+
import { RecordDiffResultSchema } from '../../protocol/messages.js';
13+
import {
14+
canonicalVisibleLines,
15+
computeScreenHash,
16+
} from '../../renderer/canonicalScreen.js';
17+
import { withOfflineReplayRenderer } from '../../replay/offlineReplay.js';
18+
import { readManifestIfExists } from '../../storage/manifests.js';
19+
import {
20+
eventLogPath,
21+
manifestPath,
22+
sessionDir,
23+
} from '../../storage/sessionPaths.js';
24+
import { diffLines } from '../../util/lineDiff.js';
25+
import { invariant } from '../../util/assert.js';
26+
27+
interface CommandOptions {
28+
context: CommandContext;
29+
json: boolean;
30+
sessionIdA: string;
31+
sessionIdB: string;
32+
atSeqA: number | undefined;
33+
atSeqB: number | undefined;
34+
}
35+
36+
interface ReplayedScreen {
37+
readonly side: RecordDiffSide;
38+
readonly visibleLines: readonly string[];
39+
}
40+
41+
function assertValidAtSeq(value: number | undefined, flag: string): void {
42+
if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
43+
throw makeCliError(ERROR_CODES.INVALID_INPUT, {
44+
message: `${flag} must be a non-negative integer.`,
45+
details: { [flag]: value },
46+
});
47+
}
48+
}
49+
50+
async function resolveSessionDirectory(
51+
home: string,
52+
sessionId: string,
53+
): Promise<string> {
54+
let sessionDirectory: string;
55+
try {
56+
sessionDirectory = sessionDir(home, sessionId);
57+
} catch (error) {
58+
throw makeCliError(ERROR_CODES.INVALID_SESSION_ID, {
59+
message: `Session ID "${sessionId}" is invalid.`,
60+
details: { sessionId },
61+
cause: error,
62+
});
63+
}
64+
65+
const manifestFile = manifestPath(sessionDirectory);
66+
const manifest = await readManifestIfExists(manifestFile);
67+
if (manifest === null) {
68+
throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, {
69+
message: `Session "${sessionId}" was not found.`,
70+
details: { sessionId, manifestPath: manifestFile },
71+
});
72+
}
73+
74+
return sessionDirectory;
75+
}
76+
77+
async function assertEventLogExists(
78+
sessionDirectory: string,
79+
sessionId: string,
80+
): Promise<void> {
81+
const eventsFile = eventLogPath(sessionDirectory);
82+
try {
83+
await access(eventsFile);
84+
} catch (error) {
85+
throw makeCliError(ERROR_CODES.REPLAY_ERROR, {
86+
message: `Session "${sessionId}" has no event log; the canonical log was deleted or never written.`,
87+
details: { sessionId, eventLogPath: eventsFile },
88+
cause: error,
89+
});
90+
}
91+
}
92+
93+
async function replayScreen(
94+
context: CommandContext,
95+
sessionId: string,
96+
targetSeq: number | undefined,
97+
): Promise<ReplayedScreen> {
98+
const sessionDirectory = await resolveSessionDirectory(
99+
context.home,
100+
sessionId,
101+
);
102+
103+
try {
104+
return await withOfflineReplayRenderer(
105+
{
106+
sessionDir: sessionDirectory,
107+
rendererName: context.rendererDefault,
108+
...(targetSeq === undefined ? {} : { targetSeq }),
109+
},
110+
async ({ backend, replayInput }) => {
111+
if (replayInput.targetSeq < 0) {
112+
// The session has not emitted any events yet, so its screen is the
113+
// valid initial blank grid. Backends reject snapshot() before the
114+
// first replayed event, so synthesize the blank screen directly.
115+
// capturedAtSeq -1 mirrors targetSeq: no event was replayed.
116+
//
117+
// The host creates events.jsonl at startup, so an empty replay is
118+
// only authoritative when the (zero-length) log file exists; a
119+
// missing file means the canonical log was deleted or never
120+
// written and must not be reported as a blank screen.
121+
await assertEventLogExists(sessionDirectory, sessionId);
122+
const blankLines = Array.from(
123+
{ length: replayInput.initialRows },
124+
() => '',
125+
);
126+
return {
127+
side: {
128+
sessionId,
129+
capturedAtSeq: -1,
130+
cols: replayInput.initialCols,
131+
rows: replayInput.initialRows,
132+
screenHash: computeScreenHash({
133+
visibleLines: blankLines.map((text) => ({ text })),
134+
}),
135+
},
136+
visibleLines: blankLines,
137+
};
138+
}
139+
140+
const snapshot = await backend.snapshot({ includeScrollback: false });
141+
return {
142+
side: {
143+
sessionId,
144+
capturedAtSeq: snapshot.capturedAtSeq,
145+
cols: snapshot.cols,
146+
rows: snapshot.rows,
147+
screenHash: computeScreenHash(snapshot),
148+
},
149+
visibleLines: canonicalVisibleLines(snapshot),
150+
};
151+
},
152+
);
153+
} catch (error) {
154+
if (error instanceof CliError) {
155+
throw error;
156+
}
157+
158+
throw makeCliError(ERROR_CODES.REPLAY_ERROR, {
159+
message: `Failed to replay session "${sessionId}" for record diff.`,
160+
details: {
161+
sessionId,
162+
...(targetSeq === undefined ? {} : { targetSeq }),
163+
},
164+
cause: error,
165+
});
166+
}
167+
}
168+
169+
function buildResultLines(result: RecordDiffResult): string[] {
170+
const header = [
171+
`--- ${result.a.sessionId} @seq ${String(result.a.capturedAtSeq)} (${result.a.screenHash.slice(0, 12)})`,
172+
`+++ ${result.b.sessionId} @seq ${String(result.b.capturedAtSeq)} (${result.b.screenHash.slice(0, 12)})`,
173+
];
174+
175+
if (result.identical) {
176+
return [...header, 'Screens are identical.'];
177+
}
178+
179+
const markers: Record<'equal' | 'delete' | 'add', string> = {
180+
equal: ' ',
181+
delete: '-',
182+
add: '+',
183+
};
184+
return [
185+
...header,
186+
...result.diff.map((entry) => `${markers[entry.op]}${entry.text}`),
187+
];
188+
}
189+
190+
export async function runRecordDiffCommand(
191+
options: CommandOptions,
192+
): Promise<void> {
193+
assertValidAtSeq(options.atSeqA, 'at-seq-a');
194+
assertValidAtSeq(options.atSeqB, 'at-seq-b');
195+
196+
const a = await replayScreen(
197+
options.context,
198+
options.sessionIdA,
199+
options.atSeqA,
200+
);
201+
// When both selectors are identical, reuse the first replay instead of
202+
// re-reading the event log: a running session may append output between the
203+
// two reads, which would make `record diff <id> <id>` spuriously
204+
// non-identical.
205+
const sameSelector =
206+
options.sessionIdA === options.sessionIdB &&
207+
options.atSeqA === options.atSeqB;
208+
const b = sameSelector
209+
? a
210+
: await replayScreen(options.context, options.sessionIdB, options.atSeqB);
211+
212+
const identical = a.side.screenHash === b.side.screenHash;
213+
const diff = identical ? [] : diffLines(a.visibleLines, b.visibleLines);
214+
if (identical) {
215+
invariant(
216+
a.visibleLines.join('\n') === b.visibleLines.join('\n'),
217+
'equal screen hashes must imply equal canonical visible text',
218+
);
219+
}
220+
221+
const rawResult = { identical, a: a.side, b: b.side, diff };
222+
const parsedResult = RecordDiffResultSchema.safeParse(rawResult);
223+
if (!parsedResult.success) {
224+
throw makeCliError(ERROR_CODES.INTERNAL_ERROR, {
225+
message: 'Generated record diff result did not match the schema.',
226+
details: { issues: parsedResult.error.issues },
227+
cause: parsedResult.error,
228+
});
229+
}
230+
231+
emitSuccess({
232+
command: 'record diff',
233+
json: options.json,
234+
result: parsedResult.data,
235+
lines: buildResultLines(parsedResult.data),
236+
});
237+
}

src/cli/main.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { runListCommand } from './commands/list.js';
1919
import { runMarkCommand } from './commands/mark.js';
2020
import { runPasteCommand } from './commands/paste.js';
2121
import { runRunCommand } from './commands/run.js';
22+
import { runRecordDiffCommand } from './commands/record-diff.js';
2223
import { runRecordExportCommand } from './commands/record-export.js';
2324
import { runResizeCommand } from './commands/resize.js';
2425
import { runScreenshotCommand } from './commands/screenshot.js';
@@ -59,6 +60,18 @@ function parseNumberOption(value: string): number {
5960
return Number(value);
6061
}
6162

63+
// Strict integer-token parser: empty, whitespace-only, fractional, partially
64+
// numeric (e.g. "", "1.5", "2junk"), or unsafe-magnitude tokens yield NaN so
65+
// command validation rejects them instead of silently truncating or rounding.
66+
function parseIntegerTokenOption(value: string): number {
67+
const token = value.trim();
68+
if (!/^[+-]?\d+$/.test(token)) {
69+
return Number.NaN;
70+
}
71+
const parsed = Number.parseInt(token, 10);
72+
return Number.isSafeInteger(parsed) ? parsed : Number.NaN;
73+
}
74+
6275
function collectStringOption(value: string, previous: string[] = []): string[] {
6376
return [...previous, value];
6477
}
@@ -800,6 +813,45 @@ async function main(): Promise<void> {
800813
.command('record')
801814
.description('Manage recorded session artifacts');
802815

816+
recordCommand
817+
.command('diff <session-id-a> <session-id-b>')
818+
.description('Diff the replayed visible screens of two recorded sessions')
819+
.option(
820+
'--at-seq-a <seq>',
821+
'Replay session A up to this Event Log sequence (default: latest)',
822+
parseIntegerTokenOption,
823+
)
824+
.option(
825+
'--at-seq-b <seq>',
826+
'Replay session B up to this Event Log sequence (default: latest)',
827+
parseIntegerTokenOption,
828+
)
829+
.option('--json', 'Emit a JSON command envelope', false)
830+
.action(
831+
wrapAction(
832+
'record diff',
833+
async (
834+
sessionIdA: string,
835+
sessionIdB: string,
836+
options: {
837+
atSeqA?: number;
838+
atSeqB?: number;
839+
json: boolean;
840+
},
841+
context: CommandContext,
842+
) => {
843+
await runRecordDiffCommand({
844+
context,
845+
json: options.json,
846+
sessionIdA,
847+
sessionIdB,
848+
atSeqA: options.atSeqA,
849+
atSeqB: options.atSeqB,
850+
});
851+
},
852+
),
853+
);
854+
803855
recordCommand
804856
.command('export <session-id>')
805857
.description('Export a recorded session artifact')

src/protocol/messages.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { z } from 'zod';
22

33
import type {
4+
RecordDiffLine as RecordDiffLineType,
5+
RecordDiffResult as RecordDiffResultType,
6+
RecordDiffSide as RecordDiffSideType,
47
RecordExportResult as RecordExportResultType,
58
ReplayTimingMode as ReplayTimingModeType,
69
RichSnapshotLine as RichSnapshotLineType,
@@ -20,6 +23,9 @@ import {
2023
} from './schemas.js';
2124

2225
export {
26+
RecordDiffLineSchema,
27+
RecordDiffResultSchema,
28+
RecordDiffSideSchema,
2329
RecordExportResultSchema,
2430
ReplayTimingModeSchema,
2531
RichSnapshotLineSchema,
@@ -213,6 +219,12 @@ export type ScreenshotResult = z.infer<typeof ScreenshotResultSchema>;
213219

214220
export type RecordExportResult = RecordExportResultType;
215221

222+
export type RecordDiffLine = RecordDiffLineType;
223+
224+
export type RecordDiffSide = RecordDiffSideType;
225+
226+
export type RecordDiffResult = RecordDiffResultType;
227+
216228
export const TypeParamsSchema = z
217229
.object({
218230
text: z.string().min(1),

0 commit comments

Comments
 (0)