Skip to content

fix(archive): don't write ANSI escape codes to a redirected (non-TTY) stdout - #1603

Open
clay-good wants to merge 6 commits into
mainfrom
claude/openspec-backlog-triage-0afdd8
Open

fix(archive): don't write ANSI escape codes to a redirected (non-TTY) stdout#1603
clay-good wants to merge 6 commits into
mainfrom
claude/openspec-backlog-triage-0afdd8

Conversation

@clay-good

@clay-good clay-good commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Status

LGTM — ready for review. Verified by CodeRabbit plus four adversarial review agents (correctness, cross-platform/Windows, stability, and whole-PR/issue-closure); every finding was addressed. Build, lint (eslint), typecheck (tsc --noEmit), and the full local test suite are green.

What was wrong

openspec archive prompts through @inquirer/prompts, which renders its live UI by writing ANSI cursor-movement escape sequences to stdout — even when stdout is not a TTY.

When archive runs with its output captured to a file or pipe — an AI agent's background task, CI, openspec archive > log.txt — those escapes are garbage in the log. Worse, in some non-TTY hosts the render loop never settles and repeats ESC[54G ESC[55G … cursor moves without end. #1526 reports the captured output growing to 19.8 GB and filling the disk.

Two archive prompts hit this: the yes/no confirmations (confirm) and the no-argument change picker (select). (The issue title guesses at an ora spinner; there is none in the archive path — @inquirer is the ANSI source.)

How it was fixed

A new confirmPrompt helper (src/utils/interactive.ts) chooses how to ask based on the streams:

  • Real terminal (stdin and stdout are TTYs): delegates to @inquirer/prompts confirm — the rich interactive prompt is unchanged.
  • Everything else (a pipe, a file redirect, a captured stdout): reads one plain line with node:readline (terminal: false), which emits no escape sequences.

The change picker can't run without a real terminal, so when either stream is not a TTY it now refuses before rendering — throwing the same "pass a change name / --yes" guidance the caught error already gave — instead of drawing a menu into the capture.

Every existing behavior is preserved:

  • Answer parsing mirrors @inquirer/confirm exactly (prefix match on y/yes and n/no, else the default), so a piped answer resolves identically to the interactive prompt.
  • printf 'y\n' | openspec archive still works (the single piped answer @inquirer ever supported).
  • When no answer can be read (closed/EOF/drained stdin, or a stdin error), it settles with an ExitPromptError-shaped error, so the existing non-interactive classification and the #1479 "rerun with --yes" guidance fire with the same messages and exit codes. Classification now also counts a redirected stdout, matching how the prompt mode is chosen (so a stdin-TTY/stdout-redirected EOF still gets the guidance, not a raw error).

Scope is deliberately limited to openspec archive — the command agents run unattended. The reusable confirmPrompt helper is the right seam if other commands ever want the same non-TTY safety (a reasonable follow-up).

Proof it works

On main, a redirected run writes ^[[56G^[[57G^[[2K^[[G (the reporter's [54G[55G pattern) into the log. After the fix, the same runs are clean (verified on macOS; the Windows-specific infinite variant is eliminated by removing the ANSI source, not reproduced directly):

$ printf 'y\n' | openspec archive demo > out.log   # ANSI escape count: 0; archives + writes spec
$ printf 'n\n' | openspec archive done1             # archives, skips spec copy, exit 0, no ANSI
$ openspec archive done3 --yes                        # no prompts, no ANSI, archives
$ openspec archive demo < /dev/null                   # exit 1, "rerun with --yes" (unchanged), no ANSI
$ openspec archive < /dev/null                        # exit 1, "pass a change name", no menu, no ANSI

Tests (test/utils/interactive.test.ts, test/core/archive.test.ts): no-ANSI in non-TTY, piped y/n/yes/no/prefix/empty/garbage, Windows CRLF (y\r\n), drained-stdin second prompt blocks not hangs, EOF and input-stream error both settle (no hang), stdin-TTY/stdout-redirected EOF classified non-interactive, and the picker is never reached in a non-terminal run. The existing archive suite passes with the confirm mock seam retargeted to confirmPrompt (keeping the real isNonInteractivePromptError), so the #1479 assertions still hold.

Notes / nits

  • No changeset is included, per the repo's default release cadence (.changeset/README.md). Given this is a high-severity disk-fill bug, a maintainer may want a dedicated changeset — happy to add one.
  • The branch was rebased on top of main (via the maintainer's merge) so it carries the latest, including fix(validate): warn on ambiguous task numbering #1523.

Closes #1526

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added reliable yes/no confirmation prompts for interactive terminals and piped or non-interactive input.
    • Prompts accept short or long-form answers, ignore case and surrounding whitespace, and apply the configured default when input is unclear.
  • Bug Fixes

    • Improved handling of end-of-input and prompt failures without hanging.
    • Prevented interactive selection without a terminal and provided rerun guidance.
    • Prevented terminal escape codes from polluting redirected archive output.
  • Documentation

    • Added troubleshooting guidance for archive prompts when output is captured without a terminal.

…to stdout

`openspec archive` asks up to three yes/no questions through @InQuirer's
`confirm`, which renders by writing ANSI cursor-movement escape sequences —
and emits them even when stdout is not a TTY. When archive runs with its
output captured to a file or pipe (an agent's background task, CI), those
escapes are noise, and in some non-TTY hosts the render loop never settles
and repeats `ESC[NNG` moves until the disk fills (reporter hit 19.8 GB).

Add `confirmPrompt` in interactive.ts: a real terminal (stdin AND stdout
TTY) still gets @InQuirer's rich prompt; every other case reads one plain
line via node:readline with `terminal:false`, emitting no escapes. Parsing
mirrors @inquirer/confirm exactly (prefix match on y/yes and n/no, else the
default), and an unreadable stdin rejects with an ExitPromptError-shaped
error so the existing #1479 "rerun with --yes" guidance is unchanged.
archive's confirmOrBlock now calls confirmPrompt.

Closes #1526

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@clay-good
clay-good requested a review from a team as a code owner August 6, 2026 18:25
@clay-good
clay-good requested review from TabishB and removed request for a team August 6, 2026 18:25
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b8c6b572-6b99-4f03-9e7e-955ffb84c0f1

📥 Commits

Reviewing files that changed from the base of the PR and between c7b2924 and 92b3261.

📒 Files selected for processing (2)
  • .changeset/archive-nontty-ansi.md
  • src/utils/interactive.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/utils/interactive.ts

📝 Walkthrough

Walkthrough

The change adds a shared confirmPrompt helper with TTY and non-TTY behavior. Archive confirmation now uses this helper. Change selection now requires TTY input and output. Tests cover prompt parsing, stream errors, archive flows, and TTY gating. Documentation covers redirected prompt output.

Changes

Confirmation prompt unification

Layer / File(s) Summary
Shared confirmation prompt
src/utils/interactive.ts, test/utils/interactive.test.ts
Adds ConfirmPrompt and confirmPrompt. TTY streams use Inquirer. Non-TTY streams use readline with parsing, defaults, ANSI-free output, EOF handling, and stream-error handling.
Archive integration and terminal-aware selection
src/core/archive.ts, test/core/archive.test.ts, docs/troubleshooting.md, .changeset/archive-nontty-ansi.md
Routes archive confirmations through confirmPrompt. Blocks non-TTY change selection before rendering the picker. Tests cover archive prompts, errors, races, flags, JSON mode, and TTY state. Documentation and the changeset describe redirected prompt output and non-terminal behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Suggested reviewers: tabishb

Sequence Diagram(s)

sequenceDiagram
  participant ArchiveCommand
  participant confirmPrompt
  participant Inquirer
  participant Readline
  participant TerminalStreams
  ArchiveCommand->>confirmPrompt: request confirmation
  alt stdin and stdout are TTYs
    confirmPrompt->>Inquirer: render interactive prompt
    Inquirer-->>confirmPrompt: return boolean
  else non-TTY stream
    confirmPrompt->>Readline: write plain-text prompt and read answer
    Readline-->>confirmPrompt: return parsed answer or prompt error
  end
  ArchiveCommand->>TerminalStreams: check stdin and stdout before change selection
  TerminalStreams-->>ArchiveCommand: allow picker or return archive_change_name_required
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing ANSI escape codes on redirected non-TTY stdout.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/openspec-backlog-triage-0afdd8

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/utils/interactive.ts`:
- Around line 93-100: Update the prompt-mode predicate used by
isNonInteractivePromptError to require both process.stdin and process.stdout to
be TTYs, matching confirmPrompt’s isTerminal logic so redirected stdout
classifies EOF as non-interactive. In test/utils/interactive.test.ts lines
304-311, add coverage with TTY stdin and non-TTY stdout and assert EOF is
classified as non-interactive.
- Around line 127-148: Add an rl.once('error', ...) handler in readYesNo that
closes the readline interface and rejects using the existing
ExitPromptError-compatible no-answer error, preserving the answered guard to
avoid duplicate completion. In test/utils/interactive.test.ts lines 293-311, add
coverage with a Readable that errors before emitting a line and assert the
rejection is ExitPromptError and isNonInteractivePromptError.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f9866e7-0066-42e5-b5e6-aeb860ff7e3f

📥 Commits

Reviewing files that changed from the base of the PR and between d578896 and 432d0a0.

📒 Files selected for processing (4)
  • src/core/archive.ts
  • src/utils/interactive.ts
  • test/core/archive.test.ts
  • test/utils/interactive.test.ts

Comment thread src/utils/interactive.ts
Comment thread src/utils/interactive.ts
Adds two regression tests surfaced by adversarial review of the #1526 fix:
- Windows CRLF piped input (`y\r\n`) parses as a clean yes with no ANSI —
  the reporter's platform, previously untested (all inputs used `\n`).
- A second prompt after stdin was already drained blocks with an
  ExitPromptError instead of hanging, exercising the readableEnded guard.

Also documents in troubleshooting.md that a redirected/agent archive run
that pipes an answer no longer writes terminal escape codes into the capture.

Refs #1526

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying openspec-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: c7b2924
Status: ✅  Deploy successful!
Preview URL: https://b516057b.openspec-docs.pages.dev
Branch Preview URL: https://claude-openspec-backlog-tria.openspec-docs.pages.dev

View logs

clay-good and others added 4 commits August 7, 2026 08:20
…dline errors

Addresses two review findings on the #1526 confirm-prompt fix:

- confirmPrompt drops to the plain reader whenever either stream is not a
  TTY, but isNonInteractivePromptError only checked stdin. A stdin-TTY /
  stdout-redirected run that hit EOF leaked the raw ExitPromptError instead
  of the #1479 "rerun with --yes" guidance. Classification now also counts a
  redirected stdout, matching how the prompt mode is chosen. (isInteractive,
  used broadly elsewhere, is left untouched.)

- readYesNo never listened for the readline/input 'error' event, so a stdin
  error would hang the promise (and go unhandled). It now settles with the
  underlying fault, guarded so the promise resolves or rejects exactly once.

Tests: TTY-stdin/redirected-stdout EOF is classified non-interactive; an
erroring input stream rejects instead of hanging; the archive usable-terminal
test now models a full terminal (both streams TTY).

Refs #1526

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-ups from a second review round:

- selectChange (the no-argument change picker) called @InQuirer's `select`
  unconditionally. `select` writes ANSI escapes to stdout even when redirected
  — the same #1526 mechanism the confirm prompts were fixed for — so
  `openspec archive > log.txt` with no change name still spewed cursor moves
  into the capture before blocking. Refuse before rendering when either stream
  is not a TTY, with the same "pass a change name / --yes" guidance the caught
  ExitPromptError already gives. A new test asserts the picker is never
  reached in a non-terminal run.

- readYesNo now removes its input-stream 'error' listener on every settle path
  (it lives on the long-lived process.stdin) and closes the readline interface
  on error too, so nothing accumulates across archive's sequential prompts.

- troubleshooting.md now notes the picker also stays clean.

Refs #1526

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
)

User-facing patch note for the archive ANSI/disk-fill fix. Also drops an
unnecessary optional-chain on the non-nullable readline handle in readYesNo
(the listener is only attached after the interface exists).

Refs #1526
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The non-TTY prompt path now stays ANSI-free, classifies redirected-output EOF consistently, and settles stream errors without leaking listeners. Gating the no-argument picker before Inquirer renders closes the same failure mode there, and the focused archive/interactive suite passes. Approved at 92b3261.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

openspec archive 陷入无限循环,ora spinner 在非 TTY 环境将 ANSI 逃逸序列重复写入 stdout 导致磁盘写满(19.8GB)

2 participants