mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(tools): resize the PTY mid-capture with --resize-at (#33781)
This commit is contained in:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user