Skip to content

feat(terminal): add openTerminal session API for deferred PTY input - #188

Merged
konard merged 6 commits into
mainfrom
issue-187-3d458fd12c95
Aug 7, 2026
Merged

feat(terminal): add openTerminal session API for deferred PTY input#188
konard merged 6 commits into
mainfrom
issue-187-3d458fd12c95

Conversation

@konard

@konard konard commented Aug 7, 2026

Copy link
Copy Markdown
Member

Problem

captureTerminal() has a batch-only lifecycle: every interaction must be known at call time, timeoutMilliseconds defaults to 30_000 and then SIGTERMs the child, and the module exported no handle to write to a live PTY. That rules out flows where the input arrives later and from elsewhere — e.g. a CLI login that prints an authorization URL, the request ends, and a separate later request must type the code into the same still-running process (re-spawning prints a new URL, so it is not a workaround).

Fixes #187

Solution

New openTerminal() export alongside captureTerminal(), exposing the machinery that already existed internally:

const session = await openTerminal({ file: 'my-cli', args: ['login'], cols: 80 });

await session.waitFor(/https:\/\/\S+/, { idleMilliseconds: 50 });
const url = session.transcript.match(/https:\/\/\S+/)[0];

// ... arbitrary time passes; nothing terminates the child ...

await session.send({ text: code, key: 'ENTER' });
await session.waitFor('Logged in');

const capture = await session.close(); // frames/transcript/asciicast as today
  • No implicit timeout while a session is open. timeoutMilliseconds has no default for openTerminal(); passing one opts back into a deadline. captureTerminal() keeps its 30 s default.
  • waitFor(pattern, { idleMilliseconds, timeoutMilliseconds }) reuses the same readiness matcher as interactions (string or RegExp) with the same idleMilliseconds restart-on-output semantics; it rejects if it times out or the child exits first.
  • send(interaction | interaction[]) accepts the same text / named-key / resize vocabulary as interactions, and an entry may carry after / idleMilliseconds to wait before being applied.
  • Explicit close({ signal, timeoutMilliseconds }) signals the child (escalating to SIGKILL), returns the usual result object, and writes artifactDirectory artifacts. dispose() is the never-throwing cleanup variant; finished() waits for a child that exits on its own.
  • Live inspection via output, transcript, frames, asciicast, running, exitStatus, exited.
  • captureTerminal() is reimplemented on top of openTerminal(), so the two paths cannot drift.

Reproduction and tests

The issue's scenario is reproduced by js/tests/fixtures/tui-session-fixture.mjs (prints an auth URL, then blocks on raw-mode input). Before this change it could not be expressed at all — there was no handle to write to a live PTY, and the default timeout would kill the child.

New tests in js/tests/terminal-capture.test.mjs:

  • keeps the child alive across a 400 ms gap with no interactions known up front, sends the code afterwards, and asserts the resulting transcript/frames/asciicast plus on-disk artifacts;
  • waitFor blocks for the requested quiescence and its own timeoutMilliseconds rejects;
  • pending waits and later send()s reject once the child exits;
  • dispose() stops a child that never exits on its own;
  • both entry points still validate file.

Runnable example: js/examples/tui-session.mjs.

bun test js/tests/terminal-capture.test.mjs  →  15 pass, 0 fail

Full suite: 762 pass / 28 fail, all 28 pre-existing failures in jq* tests because jq is not installed in this environment.

Notes

  • README documents the session API under "Interactive sessions".
  • A minor changeset is included to trigger the release.

Rust parity

The same restructure is mirrored in rust/src/terminal/capture.rs: TerminalSession (open_terminal) owns the poll loop and exposes wait_for / send / finished / close, TerminalCaptureOptions::timeout is now Option<Duration> (None while a session is open, Some(30s) for capture_terminal), and capture_terminal() is open + finish. TerminalPattern carries the text/regex readiness matcher. New tests in rust/tests/terminal_capture.rs mirror the JavaScript ones, plus a rust/changelog.d fragment and README section.

Adding .gitkeep for PR creation (default mode).
This file will be removed when the task is complete.

Issue: #187
@konard konard self-assigned this Aug 7, 2026
captureTerminal was batch-only: all interactions had to be known at call
time and a 30s default timeout killed the child. openTerminal keeps the
PTY open with no implicit timeout, exposes waitFor()/send()/close(), and
captureTerminal is now implemented on top of it.

Fixes #187
@konard konard changed the title [WIP] captureTerminal cannot keep a PTY session open for input that arrives later (batch-only lifecycle, 30s default kill) feat(terminal): add openTerminal session API for deferred PTY input Aug 7, 2026
@konard
konard marked this pull request as ready for review August 7, 2026 19:52
konard added 4 commits August 7, 2026 19:57
Adds open_terminal/TerminalSession with wait_for, send, close, and finish,
makes TerminalCaptureOptions::timeout optional (None keeps a session alive),
and reimplements capture_terminal on top of the session.
@konard

konard commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Working session summary

All three CI workflows now pass on the branch, and PR #188 is ready (not a draft, mergeable, main is an ancestor, working tree clean).

What I fixed in this last stretch (all Windows-only test issues, no production-code changes):

  • keeps the child alive for input that arrives much later — the fixture exits on its own, so the test now awaits session.exited before close() instead of racing the kill against it (Windows reported -1073741510).
  • rejects pending waits and later sends once the child exits — on Windows the teardown surfaces via the PTY host, not a child exit status, and ConPTY drops the output of such a short-lived process, so the exit-code and transcript assertions are gated on !isWindows; the rejection and the "already exited" send() assertions still run everywhere.
  • drives input and resize while retaining settled, deduplicated frames (pre-existing test) — it renders every artifact including the GIF and already took 5.3 s on main's Windows runs, i.e. within noise of bun's 10 s default; it timed out at 16–17 s twice. Gave it an explicit 60 s timeout.

PR: #188 (description now also covers the Rust parity port).

Final runs: JavaScript checks ✅, Language parity check ✅, Rust checks ✅.


This summary was automatically extracted from the AI working session output.

@konard

konard commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

🤖 Solution Draft Log

This log file contains the complete execution trace of the AI solution draft process.

💰 Cost: $6.151650

📊 Context and tokens usage:

Claude Opus 5: (2 sub-sessions)

  1. 116.9K / 1M (12%) input tokens, 38.6K / 128K (30%) output tokens
  2. 60.0K / 1M (6%) input tokens, 8.1K / 128K (6%) output tokens

Total: (3.4K new + 142.2K cache writes + 6.6M cache reads) input tokens, 56.6K output tokens, $6.151650 cost

🤖 Models used:

  • Tool: Anthropic Claude Code
  • Requested: opus (claude-opus-5)
  • Thinking level: off (disabled)
  • Model: Claude Opus 5 (claude-opus-5)

📎 Log file uploaded as Gist (2798KB)


Now working session is ended, feel free to review and add any feedback on the solution draft.

@konard
konard merged commit 7ca60a3 into main Aug 7, 2026
22 checks passed
@konard

konard commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

🎉 Auto-merged

This pull request has been automatically merged by hive-mind.

  • All CI checks have passed

Auto-merged by hive-mind with --auto-merge flag

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.

captureTerminal cannot keep a PTY session open for input that arrives later (batch-only lifecycle, 30s default kill)

1 participant