feat(tools): resize the PTY mid-capture with --resize-at (#33781)

This commit is contained in:
Neil
2026-10-25 22:27:16 -07:00
committed by GitHub
parent 8cc8024504
commit 1b712dd08a
2 changed files with 80 additions and 6 deletions
@@ -25,6 +25,9 @@ Options
--cols <n> --rows <n> Pin the PTY size (default: this terminal's size, else 120x40)
--duration <seconds> Stop unattended after N seconds
--send "<ms>:<text>" Type <text> into the PTY at <ms> (repeatable; \\r \\n \\t \\e escapes)
--resize-at <ms>:<cols>x<rows>
Resize the PTY at <ms> (repeatable). The transcript gets an inline
CSI 8;<rows>;<cols>t marker where it happened, and the sidecar lists it
--note "<text>" Recorded in the <name>.meta.json sidecar
--scan <file...> 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 }
}
/** `"<ms>:<cols>x<rows>"` — 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 "<ms>:<cols>x<rows>", 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 }
@@ -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'