From 1b712dd08a8d09816c231c4333cc378d3dff2d20 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:27:16 -0700 Subject: [PATCH] feat(tools): resize the PTY mid-capture with --resize-at (#33781) --- .../scripts/capture-agent-pty-transcript.mjs | 56 +++++++++++++++++-- .../pty-transcript-secret-scan.test.mjs | 30 +++++++++- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/config/scripts/capture-agent-pty-transcript.mjs b/config/scripts/capture-agent-pty-transcript.mjs index 87872aa1233..4c5ec1355d1 100644 --- a/config/scripts/capture-agent-pty-transcript.mjs +++ b/config/scripts/capture-agent-pty-transcript.mjs @@ -25,6 +25,9 @@ Options --cols --rows Pin the PTY size (default: this terminal's size, else 120x40) --duration Stop unattended after N seconds --send ":" Type into the PTY at (repeatable; \\r \\n \\t \\e escapes) + --resize-at :x + Resize the PTY at (repeatable). The transcript gets an inline + CSI 8;;t marker where it happened, and the sidecar lists it --note "" Recorded in the .meta.json sidecar --scan Scan existing transcripts for identifiers/credentials and exit --redact With --scan: rewrite each finding as a same-length placeholder @@ -33,7 +36,15 @@ Press Ctrl-] to end a capture. That key is consumed here, so the agent keeps wha dialog it is showing — which is the only way to capture a dialog that owns the screen.` function parseArgs(argv) { - const options = { cols: null, rows: null, duration: null, scan: [], sends: [], redact: false } + const options = { + cols: null, + rows: null, + duration: null, + scan: [], + sends: [], + resizes: [], + redact: false + } const command = [] let cursor = 0 let afterSeparator = false @@ -58,6 +69,9 @@ function parseArgs(argv) { } else if (arg === '--send') { cursor += 1 options.sends.push(parseSend(argv[cursor])) + } else if (arg === '--resize-at') { + cursor += 1 + options.resizes.push(parseResizeAt(argv[cursor])) } else if (arg.startsWith('--')) { const key = arg.slice(2) cursor += 1 @@ -93,6 +107,20 @@ function parseSend(value) { return { atMs, text } } +/** `":x"` — a PTY resize at a fixed offset, to capture the agent's repaint. */ +function parseResizeAt(value) { + const match = /^(\d+):(\d+)x(\d+)$/.exec(String(value ?? '')) + if (!match || Number(match[2]) < 1 || Number(match[3]) < 1) { + throw new Error(`--resize-at expects ":x", got ${String(value)}`) + } + return { atMs: Number(match[1]), cols: Number(match[2]), rows: Number(match[3]) } +} + +// Why XTWINOPS "resize to rows;cols": a replay can apply it, and xterm ignores it by default. +function resizeMarker(cols, rows) { + return `${ESC}[8;${rows};${cols}t` +} + function runScan(files, redact) { let failed = false for (const file of files) { @@ -154,11 +182,16 @@ ${String(error)}` const sink = createWriteStream(outPath) let recording = true + let recordedBytes = 0 + const record = (bytes) => { + sink.write(bytes) + recordedBytes += bytes.length + } term.onData((chunk) => { const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk // Stop recording before kill or shutdown repaint overwrites the dialog/mid-turn frame being captured. if (recording) { - sink.write(bytes) + record(bytes) } process.stdout.write(bytes) }) @@ -190,12 +223,24 @@ ${String(error)}` }) // Scripted keystrokes use the real PTY when CI/agents have no interactive TTY. const sendTimers = options.sends.map((send) => setTimeout(() => term.write(send.text), send.atMs)) + const resizes = [] + const resizeTimers = options.resizes.map((resize) => + setTimeout(() => { + if (!recording) { + return + } + // Marker first: the repaint the resize triggers arrives after it in the byte stream. + resizes.push({ ...resize, byteOffset: recordedBytes }) + record(Buffer.from(resizeMarker(resize.cols, resize.rows), 'utf8')) + term.resize(resize.cols, resize.rows) + }, resize.atMs) + ) const durationTimer = options.duration === null ? null : setTimeout(stop, options.duration * 1000) const exitCode = await new Promise((resolveExit) => { term.onExit(({ exitCode: code }) => resolveExit(code ?? 0)) }) - for (const timer of sendTimers) { + for (const timer of [...sendTimers, ...resizeTimers]) { clearTimeout(timer) } if (durationTimer !== null) { @@ -207,7 +252,7 @@ ${String(error)}` process.stdin.pause() await new Promise((done) => sink.end(done)) - writeMeta(outPath, { command, cols, rows, note: options.note ?? null, exitCode }) + writeMeta(outPath, { command, cols, rows, resizes, note: options.note ?? null, exitCode }) const findings = scanTranscriptForSecrets(readFileSync(outPath, 'utf8')) console.log(`\nTranscript: ${outPath}`) console.log(formatFindings('scrub check', findings)) @@ -231,6 +276,7 @@ function writeMeta(outPath, details) { command: details.command, cols: details.cols, rows: details.rows, + ...(details.resizes.length > 0 ? { resizes: details.resizes } : {}), note: details.note, exitCode: details.exitCode }, @@ -268,4 +314,4 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) ) } -export { parseArgs, resolveSpawn } +export { parseArgs, resizeMarker, resolveSpawn } diff --git a/config/scripts/pty-transcript-secret-scan.test.mjs b/config/scripts/pty-transcript-secret-scan.test.mjs index 8c80f9b7f98..630f0fb9efc 100644 --- a/config/scripts/pty-transcript-secret-scan.test.mjs +++ b/config/scripts/pty-transcript-secret-scan.test.mjs @@ -9,7 +9,7 @@ import { redactTranscript, scanTranscriptForSecrets } from './pty-transcript-secret-scan.mjs' -import { parseArgs, resolveSpawn } from './capture-agent-pty-transcript.mjs' +import { parseArgs, resizeMarker, resolveSpawn } from './capture-agent-pty-transcript.mjs' describe('pty transcript secret scan', () => { it('finds the account row of a ready screen', () => { @@ -121,6 +121,34 @@ describe('capture argv', () => { expect(options.redact).toBe(true) }) + it('collects repeatable mid-capture resizes', () => { + const { options } = parseArgs([ + '--name', + 'codex-conpty-resize', + '--resize-at', + '3000:80x24', + '--resize-at', + '6000:140x40', + '--', + 'codex' + ]) + expect(options.resizes).toEqual([ + { atMs: 3000, cols: 80, rows: 24 }, + { atMs: 6000, cols: 140, rows: 40 } + ]) + }) + + it('rejects a malformed resize', () => { + expect(() => parseArgs(['--resize-at', '3000:80'])).toThrow(/--resize-at expects/) + expect(() => parseArgs(['--resize-at', '3000:0x24'])).toThrow(/--resize-at expects/) + }) + + it('marks a resize with XTWINOPS rows;cols, which the secret scan ignores', () => { + const marker = resizeMarker(80, 24) + expect(marker).toBe(`${String.fromCharCode(27)}[8;24;80t`) + expect(scanTranscriptForSecrets(`before${marker}after`)).toEqual([]) + }) + it('routes a Windows shim through cmd.exe, which node-pty cannot spawn directly', () => { expect(resolveSpawn(['agy.cmd', '--model', 'sonnet'])).toEqual( process.platform === 'win32'