diff --git a/dockerfiles/Dockerfile.hermes-agent b/dockerfiles/Dockerfile.hermes-agent index 46e04952..15f680df 100644 --- a/dockerfiles/Dockerfile.hermes-agent +++ b/dockerfiles/Dockerfile.hermes-agent @@ -1623,9 +1623,12 @@ RUN case "${HERMES_KANIKO_HEREDOC_COMPAT}" in 0) ;; 1) python /tmp/hermes-kaniko # alternate screen and current viewport after a bounded replay. COPY dockerfiles/patch-hermes-terminal-replay.py /tmp/patch-hermes-terminal-replay.py COPY dockerfiles/hermes-terminal-replay-regression.py /tmp/hermes-terminal-replay-regression.py +COPY dockerfiles/hermes-terminal-resume-regression.js /tmp/hermes-terminal-resume-regression.js RUN /opt/hermes/.venv/bin/python /tmp/patch-hermes-terminal-replay.py \ && HERMES_SOURCE_ROOT=/opt/hermes /opt/hermes/.venv/bin/python \ - /tmp/hermes-terminal-replay-regression.py + /tmp/hermes-terminal-replay-regression.py \ + && node /tmp/hermes-terminal-resume-regression.js \ + && node --check /opt/hermes/ui-tui/dist/entry.js # The dashboard keeps keyboard and paste handling unchanged, but forwards only # bounded SGR wheel reports when the TUI has explicitly enabled mouse tracking. diff --git a/dockerfiles/Dockerfile.hermes-agent.dockerignore b/dockerfiles/Dockerfile.hermes-agent.dockerignore index b7fe9193..09fdd2b5 100644 --- a/dockerfiles/Dockerfile.hermes-agent.dockerignore +++ b/dockerfiles/Dockerfile.hermes-agent.dockerignore @@ -7,6 +7,7 @@ !dockerfiles/hermes-session-migrate.py !dockerfiles/patch-hermes-terminal-replay.py !dockerfiles/hermes-terminal-replay-regression.py +!dockerfiles/hermes-terminal-resume-regression.js !dockerfiles/patch-hermes-dashboard-wheel.js !dockerfiles/hermes-dashboard-terminal-input.ts !dockerfiles/hermes-dashboard-terminal-input.test.ts diff --git a/dockerfiles/hermes-terminal-resume-regression.js b/dockerfiles/hermes-terminal-resume-regression.js new file mode 100644 index 00000000..2d3c9125 --- /dev/null +++ b/dockerfiles/hermes-terminal-resume-regression.js @@ -0,0 +1,33 @@ +/** Exercise the pinned source and shipped SIGCONT handlers without starting Hermes. */ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const root = process.env.HERMES_SOURCE_ROOT || "/opt/hermes"; + +const handlers = [ + ["ui-tui/packages/hermes-ink/src/ink/ink.tsx", /^ private handleResume = \(\) => \{[\s\S]*?^ }/m], + ["ui-tui/dist/entry.js", /^ handleResume = \(\) => \{[\s\S]*?^ };/m], +]; + +for (const [file, pattern] of handlers) { + const source = fs.readFileSync(path.join(root, file), "utf8"); + const match = source.match(pattern); + assert.ok(match, `missing pinned SIGCONT handler: ${file}`); + // Evaluate only the reviewed handler, with renderer effects replaced by spies. + const Renderer = new Function(`return class { ${match[0].replace("private ", "")} }`)(); + for (const [isTTY, isPaused, expected] of [ + [true, false, 1], + [true, true, 0], + [false, false, 0], + ]) { + const renderer = new Renderer(); + let repaints = 0; + renderer.options = { stdout: { isTTY } }; + renderer.isPaused = isPaused; + renderer.altScreenActive = true; + renderer.reenterAltScreen = () => { repaints++; }; + renderer.handleResume(); + assert.equal(repaints, expected, `${file}: TTY=${isTTY}, paused=${isPaused}`); + } +} +console.log("dashboard SIGCONT source and shipped handlers passed"); diff --git a/dockerfiles/patch-hermes-terminal-replay.py b/dockerfiles/patch-hermes-terminal-replay.py index a7dc24ac..dc9265c0 100644 --- a/dockerfiles/patch-hermes-terminal-replay.py +++ b/dockerfiles/patch-hermes-terminal-replay.py @@ -62,6 +62,25 @@ def patch_sources(root: Path) -> None: " self._has_attached = True\n", ), ), + # The pinned image ships readable esbuild output without the TUI build + # dependencies. Keep its source and shipped handler in sync, and reject + # either anchor changing rather than resolving a new dependency graph. + "ui-tui/packages/hermes-ink/src/ink/ink.tsx": ( + ( + " private handleResume = () => {\n" + " if (!this.options.stdout.isTTY) {\n", + " private handleResume = () => {\n" + " if (!this.options.stdout.isTTY || this.isPaused) {\n", + ), + ), + "ui-tui/dist/entry.js": ( + ( + " handleResume = () => {\n" + " if (!this.options.stdout.isTTY) {\n", + " handleResume = () => {\n" + " if (!this.options.stdout.isTTY || this.isPaused) {\n", + ), + ), } prepared = {} for relative, replacements in patches.items(): @@ -71,7 +90,8 @@ def patch_sources(root: Path) -> None: if source.count(before) != 1: raise ValueError(f"Hermes terminal replay patch context changed: {relative}") source = source.replace(before, after, 1) - compile(source, str(path), "exec") + if path.suffix == ".py": + compile(source, str(path), "exec") prepared[path] = source for path, source in prepared.items(): path.write_text(source)