Skip to content

feat(run): entrypoint terminal modes: heph's PTY by default, --raw for fidelity - #524

Open
raphaelvigee wants to merge 31 commits into
raphaelvigee/entrypoint-driverfrom
raphaelvigee/entrypoint-terminal-modes
Open

raphaelvigee wants to merge 31 commits into
raphaelvigee/entrypoint-driverfrom
raphaelvigee/entrypoint-terminal-modes

Conversation

@raphaelvigee

@raphaelvigee raphaelvigee commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Stacked on #516. Spec: https://claude.ai/artifact/SzmPFWNomk87UJVGSQy4dw

On a terminal, heph run //x now runs an entrypoint behind a PTY heph owns, with no log and no redaction. --raw drops the TUI and heph's wrapping, and the tool inherits the caller's fds. A redirected or background run inherits too, so pipes stay byte-exact. On a build target, --raw only changes the display, and a build target's --shell loses its log and redaction and follows a live resize.

HEPH_LOG=entrypoint_mode=debug heph run //x says which mode a run took, and why.

Release note

heph run on an entrypoint now runs the tool behind a PTY heph owns when stdin, stdout and stderr are all your terminal and the run is the foreground job. Ctrl-Z does nothing on a plain tool there. --raw is the new way out: no TUI, the tool inherits your fds and gets real Ctrl-Z, with two signal limits; any redirected or background run inherits too. On a build target, --raw only prints the action's output plainly. Credentials: no entrypoint run and no build target's --shell is logged or redacted, so env in a build target's --shell now shows tokens that used to read [redacted]. Stdin: piped or < file stdin no longer reaches a plain heph run build, because it was not part of the cache key and could poison an artifact. See docs/ENTRYPOINTS.md.

Verification

CI runs under ci/force-ci because this behaviour is platform-specific.

  • Decision 5's Linux spike passed. plain_tool_under_ctrl_z_keeps_running, tool_handling_sigtstp_sees_it and self_sigstop_suspends_and_resumes ran and passed on linux/amd64 and linux/arm64 (run 41211774247), so Linux matches the macOS spike.
  • Board review on a853179, with verdicts in the spec:
    • code-quality BLOCKED: heph could stop itself with nobody to resume it (ssh -t, tmux, docker exec). Fixed; round 2 found the docker exec hole, fixed with a unit test and no further round.
    • hermeticity NOT HERMETIC: piped stdin reached a cached build on macOS, through the macOS TUI fix. A plain build now gets stdin only when fd 0 is a terminal; round 2 HERMETIC WITH EXEMPTIONS (typed keystrokes, pre-existing).
    • feature-quality PASS WITH FOLLOW-UPS, compatibility COMPATIBLE, product-vision SHIP WITH CHANGES; applied.
    • Re-reviewed on 8437e92: code-quality PASS, compatibility COMPATIBLE, product-vision SHIP, feature-quality PASS WITH FOLLOW-UPS (minors applied in 5b98e22, not re-reviewed).
  • docker exec, measured on linux/arm64 in a container: PPid: 0 confirmed. A tool that stops itself is continued, with heph as the direct exec'd command. Exit code and termios were captured only with an sh -c between them, and no pre-fix build was compared.
  • Performance, macOS only, 1 GiB to a real PTY: default 20.8 s, down from 33.4 s after the review; --raw 8.3 to 9.2 s; by hand 8.9 s. So --raw adds nothing per byte, and the default costs about 2.3x. Warm true: --raw 18.3 ms, default 20.6 ms, by hand 2.8 ms. Warm gopls version, no tty: --raw 32.7 ms, feat: entrypoints — invoke a target in your world through the driver's ordinary run #516 32.5 ms (within noise), by hand 14.8 ms. Linux/arm64 in an OrbStack VM, 1 GiB to a PTY, n=5, so only the ratios transfer: default 8.45 s, --raw 4.28 s, by hand 4.81 s. Default is about 2.0x --raw there, and --raw matches by hand within noise. Not measured on real Linux hardware.
  • A CI failure that turned out to be a test race: local_runner_shell_keeps_working failed once on darwin/arm64. bash can lose a Ctrl-C; reproduced with added jitter on a bare PTY with no heph (19/800). The test now resends it, 1000/1000 jittered, so it would no longer catch a one-off dropped Ctrl-C.

Per-platform differences, for the user to accept or not

  1. Drain on macOS has no window. Measured on macOS: when the session leader exits, the master gets EOF within milliseconds, and a surviving daemon's writes then fail with EIO. So a daemon's late output is lost at once, with no drain limit and no warning. On Linux, default_run_drain_is_bounded passes with the limit and the warning's byte count asserted: heph waits up to the limit and warns. stop_during_drain_is_ignored has a window only on Linux too. Avoiding this would need a helper process in the session.
  2. \r\r\n on macOS. XNU emits one every 4095 bytes once a slave's queue fills, on a plain PTY with no heph involved. It renders the same. The two output-complete tests collapse it on macOS only; Linux keeps the exact comparison. Clamping the baud rate would remove it but lie to tools, so that was not done.
  3. crossterm use-dev-tty, macOS only. kqueue rejects /dev/tty, so a redirected stdin killed the TUI and the tool never ran. Same behaviour on both OSes by a different implementation.

Departures from the spec

  • raise(SIGSTOP), not kill(getpid(), SIGSTOP), in Stops step 4. "That call returning is the resume" needs the stop to land before the return; on Linux kill may return on a worker thread first. Reasoned, not measured.
  • pty_fallback_warns_when_plugin_offers_none is in run.rs and crates/e2e/tests/entrypoint.rs, not plugin-exec: the fallback is host and engine logic.
  • Driver::supports_shell is a new defaulted trait method, so the engine can gate on the driver that spawns.
  • Added beyond the table: a test-only HEPH_PTY_DRAIN_MS, as the spec's "tests can inject the limit" needs; a test-only HEPH_PTY_OPEN_FAILS; a cdylib stop/fg test.
  • entrypoint_kill_under_a_terminal now passes --raw, as the spec says: its limit belongs to the shared group.
  • timeout at an interactive shell: job control makes it a group leader and the foreground job, so timeout 5 heph run //x under bash -i takes the PTY path. Under a script it inherits, as the spec says. Docs state which.

Open items settled by implementation

  • Drain limit: DRAIN_DEADLINE, 500ms.
  • kill -TSTP <heph>: takes the same stop path. The handler lasts for the process, so one after the tool exits does nothing.
  • An entrypoint's --shell with no foreground terminal: never raw, and a stop just continues the tool.

Piped stdin reaching EOF only stops the pump; no VEOF is sent, so a piped --shell tool may wait. The spec is silent on this, and the old pump did the same.

Left for the user (in the spec's Open)

  • A live resize cannot reach a tool under oci.
  • File the remaining stdin hole, typed keystrokes reaching a cached build, as an issue.
  • Whether to write the execve spec.

Known CI problem, not from this PR

Test linux/arm64 can fail in plugingo-e2e with No space left on device. It failed three times at 438dc90 and passed at 5b98e22 with the same code for that job; the head commit touched only a bin-e2e fixture. The user sent this to a separate session.

Decided by the user, 2026-10-31

🤖 Generated with Claude Code

@raphaelvigee raphaelvigee added the ci/force-ci Run full CI on this stacked PR, which the gate would otherwise skip label Oct 31, 2026
@raphaelvigee
raphaelvigee added this pull request to stack #525 October 31, 2026 13:36
@raphaelvigee
raphaelvigee force-pushed the raphaelvigee/entrypoint-terminal-modes branch from a5e6b4a to a853179 Compare October 31, 2026 14:45
@raphaelvigee

Copy link
Copy Markdown
Member Author

Follow-ups filed: #526 (oci terminal and resize) and #527 (typed keystrokes reaching a cached build). The execve spec is deferred. The macOS use-dev-tty difference is accepted.

raphaelvigee and others added 24 commits November 1, 2026 09:29
…eground spawn

Adds RunStdio::Pty, moved once from the host's handover to the spawn, with its
slave crossing the plugin seam by number (ABI 0.17.2, a distinct RUN_STDIO_PTY so
an old plugin refuses it), and a stop/continue pair of frames. pty.rs moves to
crates/proc with CLOEXEC ends and one raw-mode owner.

A build target's --shell drops its log and redaction and follows a live resize.
Nothing picks the PTY path yet: run.rs still inherits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
should_use_tui tests fd 2 and disqualifies only when tcgetpgrp(2) succeeds and
differs from our group, so a piped stdin keeps the TUI and `heph run //x &` no
longer goes raw before the handover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PtySession opens the pair, makes the caller's terminal raw and pumps stdin to
the master and the master to fd 1 with one fixed buffer per direction, so a slow
fd 1 backs up into the tool rather than into memory. It answers the tool's stops
in the spec's order, and finish() drains under a limit and restores last.

The stop raises SIGSTOP rather than kill(getpid()): on Linux kill can return on
a worker thread before the stop lands, and that return is the resume. Reasoned
from the kernel's behaviour, not measured.

raw_terminal_restores_what_it_captured was red on macOS: BSD sets PENDIN itself
when ICANON comes back on. It is state rather than a setting, so it is masked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s PTY

heph now decides at the handover. A run on a terminal it is the foreground job
of, whose driver offered the PTY, takes the pump; everything else inherits, and
each run says why in a debug! under the target `entrypoint_mode`. A `--shell`
always takes the PTY, so its wrapper streams go. A build target's `--shell`
follows live window size changes through a watch channel.

`finish` runs before the pause guard drops, so the terminal is cooked again
before the TUI resumes or an error renders.

The `--raw` flag lands here because the mode rule reads it. On an entrypoint it
already takes the inherited path; the next commit gives it its display.

entrypoint_kill_under_a_terminal now passes `--raw`: it pins the shared group's
X5 limit, which the spec keeps for `--raw` and which a default run no longer has.

exec's `--shell` tolerates having no terminal to copy termios from, as the pump
does. ABI_SEMVER_C said 0.17.1 where ABI_SEMVER said 0.17.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No TUI, and the default log filter drops to warn, which is all the CI view needs
to go quiet: it prints only info! on a healthy run. The logger starts before the
real parse, so the soft parse moves ahead of it; a look through argv would take
`-- --raw` for heph's. HEPH_LOG still has the last word.

On a build target it is a host-side wrapper that never crosses the seam: output
through the driver's tee, so redacted as in log.txt, and no stdin, or two runs
piped different bytes would share a hashin. A cache hit prints nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The harness gains an interactive shell and a job-control script above heph,
resize, terminal close, termios, and `HEPH_PTY_OPEN_FAILS` for the one failure
nothing else can provoke. default_run_output_complete,
failing_tool_output_is_complete and redirected_run_is_byte_exact are red on
real bugs still to fix. stop_during_drain_is_ignored and
default_run_drain_is_bounded are ignored: macOS revokes the slave when the tool
exits, so no drain window exists there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`heph run //x < file` and `… | heph run //x` with stderr on a terminal
failed with "The cursor position could not be read": the tool never ran,
which read as empty stdin. Nothing consumed fd 0. With stdin not a terminal
crossterm reads /dev/tty, and macOS kqueue refuses that with EINVAL
(measured), which `position_to` swallows until its timeout.

`use-dev-tty`, on macOS only, swaps crossterm's mio source for one polling
with select. Semantics stay uniform; Linux's epoll accepts /dev/tty. The
limit is select's: a terminal fd at 1024 or above fails, and the source
opens at TUI start, far below it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hell

Job control has already made `timeout` the leader of the foreground job at
an interactive shell, so heph stays in the foreground and takes the PTY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
macOS has no drain window, measured with a plain PTY and no heph: when a
session leader exits, the master reads EOF within milliseconds, while its
HUP-ignoring child stays alive and its writes fail with EIO. A slave that is
not the controlling terminal reaches EOF only when the child exits.

So every OS asserts that the child is alive, that heph leaves within the
limit plus a margin, and the tool's status. The limit itself and the warning
are asserted on Linux alone.

The fixture exits only once the child has written its pid, and the child
survives a failing `echo`: with the driver's `-e`, EIO would have ended it, and
the HUP could beat its `trap`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ine endings

- every_line_arrived tolerates `\r+\n` on macOS only. XNU emits `\r\r\n` every
  4095 bytes once a PTY slave's queue fills, measured on a plain PTY with no
  heph involved. Linux keeps the exact `\r\n`.
- local_runner_shell_keeps_working and build_target_shell_forwards_resize: typed
  stdin and a live resize reach a `--shell` tool, with and without a wrap runner.
- cdylib_entrypoint_default_run_gets_slave, cdylib_raw_inherits_fds and
  cdylib_tool_stopped_resumes_on_fg: the first real crossing of the PTY slave
  and the stop/continue frames over the plugin seam. The test cdylib now reports
  its `tty` to a separate file and has a mode that stops itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`printf B | heph run //x`, with stderr a terminal and no `--raw`, went through
the build path's interactive wrapper, which handed fd 0 to the action. Stdin is
no input of the cache key, so the second run got the artifact of the first.

A plain build's wrapper now passes stdin only when fd 0 is a terminal, and a
`--shell` as before. Typed keystrokes stay as they are: that hole is reserved
for the user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
heph leading its session or otherwise orphaned (`ssh -t host heph run //x`,
`tmux new heph ...`) stopped itself with nobody to resume it, and hung. A fourth
check before a stop: the parent is in heph's session. An error counts as no. On
failure heph skips the stop and continues the tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A wrapper in heph's group (`/bin/sh -c 'heph run //x; echo after'`, `make`) never
stops with it, so the shell never saw a stop and the prompt never came back. heph
now skips the stop unless it leads its group, and continues the tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SIGTTOU is ignored, so a `tcsetattr` from the background succeeds: heph woken by
`bg` overwrote the shell's termios on its way to stopping again, and so did a run
ending there. `RawTerminal::restore`, and with it `Drop` and every stop, now acts
only while heph is the foreground job. A driver's `--shell` no longer goes raw
from the background either, as nothing would undo it.

The comment in main.rs claimed these calls fail with EIO. `tcsetattr` succeeds;
only a read fails.

A restore now needs a controlling terminal, so RawTerminal's test runs in a
session of its own, and the pump's test leaves the restore to
`default_run_restores_the_terminal`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`kill -STOP <heph>`, `bg`, `fg` killed the keyboard: a stdin read from the
background returns EIO, which ended stdin for good. A read error while heph is
not the foreground now goes through the stop path, as the spec's stops do, and
stdin comes back with `fg`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tool can be continued between the `WNOWAIT` wait that reported the stop and
the wait that consumes it. Sending `Stopped` then stopped heph for a stop that was
already over. It now goes out only when the consuming `waitid` reported a child.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A panic that skips `RawTerminal`'s `Drop` left the terminal raw. The session now
registers a restore while raw, and the panic hook runs it. Scoped, so the TUI's
closure is back for the second Ctrl-C once the session ends. A panic does not run
the TUI's own: it says "aborted (second Ctrl-C)", and the hook already undoes the
TUI's raw mode.

Fatal signals (SEGV, BUS) and an abort across the plugin seam stay uncovered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
R22: nothing is teed from a run on the caller's terminal, so `writes_log` is true
for a captured run only, with no file and no artifact on `Inherit`. Also words the
PTY arm's drain warning for what it is, as a session has no log tail to truncate,
and says on `Entrypoint`'s `Hash` derive that `offers_pty` is no part of what a
driver may hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each chunk of output paid two trips to the blocking pool, a write and a
flush. A flush now waits while the master has more to read, and is immediate
once it has none, so a prompt is not held back. The output buffer grows to
64 KiB; stdin keeps 8 KiB.

The stop path and the drain still flush as soon as all they hold is written,
because a tool that keeps writing would otherwise put the flush off past
their bound.

1 GiB to a PTY on darwin/arm64, interleaved, n=3: 31.93 s before, 20.83 s
after, against 8.31 s for --raw. Linux not measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
macOS now bounds doubled line endings rather than trimming them all, the drain
warning must carry a non-zero byte count, and a third TERM delivery fails. The
stop-during-drain test drops a wait that was no post-signal barrier and says
what macOS can prove. raw_requested no longer splits no_config_tolerable from
its doc comment, and a --raw after the separator does not quiet the log. The
ABI fd tests start at 200, below a macOS soft limit of 256.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A plugin that fails before decoding the run's options never takes the slave,
which then stays open until heph exits. The comment said otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
raphaelvigee and others added 7 commits November 1, 2026 09:29
Lost background output, `\r\r\n` on macOS, seeing the mode under --raw, a
self-stopping tool that keeps running with no shell to resume heph, and reset
after a fatal signal. --help gains the mode, reset and --shell's output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getppid returns 0 there, and getsid(0) answers for heph itself, so under
docker exec -it the session check passed and heph stopped itself for good.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
local_runner_shell_keeps_working failed once on darwin/arm64 with `^C` as
the last output and no prompt within 60s. The log's capture looks cut at the
help text only because stderr is interleaved; the session got as far as the
Ctrl-C.

A bash in a `sleep` loop with an INT trap sometimes ignores a Ctrl-C and
loops on. On a bare PTY with no heph, bash 3.2 and 5.3 hung on 11/500 and
8/300 with the Ctrl-C's timing spread over one nap, and a second Ctrl-C
ended the loop. With that spread added to the test, it failed 8/300 under a
runner and 13/300 without one, so the wrap runner is not the cause. With the
resend, 1000/1000 passed.

`sleep & wait` is not an alternative: it left /bin/bash 3.2 spinning on the
CPU (14/500).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hook also fires on panics tokio catches, so it could leave the terminal
cooked under a tool that was still running. An uncaught panic now relies on
`RawTerminal`'s `Drop`, since release unwinds. A panic that aborts leaves the
terminal raw until `reset`, which docs/ENTRYPOINTS.md now says.

The scoped restore stays for `hard_abort`: two presses accepted just before the
handover can still reach it with the session raw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The piped-stdin test dropped an assertion a TUI run could never fail, since it
never prints a successful action's output, and gives each piped value its own
workspace so each can fail alone. A new unit test pins that a burst of chunks
does not cost a flush per chunk.

Records the measured Ctrl-C loss behind interrupt_to_prompt's resend, and names
the commands behind the self-stop limit in the docs, adding a closed terminal
and an exited parent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The signal these tests send lands as the resumed tool writes. bash's trap
handlers do not restart, so `echo` can fail with EINTR, and the driver's `-e`
turned that into the tool's exit, status 1, with heph following it. The barrier
then hit ESRCH.

Measured on the linux/arm64 CI artifact under load: 7 failures in 1040 runs,
each with `echo: write error: Interrupted system call` where inspected. With
`set +e` in the fixture: 0 in 800.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@raphaelvigee
raphaelvigee force-pushed the raphaelvigee/entrypoint-terminal-modes branch from 438dc90 to bd76a8f Compare November 1, 2026 09:31

This branch has not been deployed

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

Labels

ci/force-ci Run full CI on this stacked PR, which the gate would otherwise skip

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant