Repository navigation
fix(cli): resolve prompts by policy when stdin or stdout is not a TTY - #446
Merged
Merged
Conversation
`hack env unset KEY` with a piped stdin and no HACK_NO_INTERACTIVE never returned and burned a full CPU: `confirmSafe` only consulted the explicit opt-out, so a TTY-less run entered the clack confirm, whose readline loop cannot settle on a non-TTY stdin (HACK-1385). - `confirmSafe` now gates on `canPrompt()` (TTY on both streams plus the opt-outs) and resolves every TTY-less run by its declared policy within milliseconds; `doctorConfirm` notes skipped destructive steps on the same condition. - Raw clack prompts without a guard are routed through the convention: `secrets` (name, value, delete), the `session` picker and attached-workspace choice, `mcp` target selection, `update` (stdin as well as stdout), and the `agent` client picker. - Regression tests: unit cases for each policy without a TTY, and an e2e spawn of `env unset` and `secrets delete` with piped stdin under a 5 s kill bound asserting exit, bounded CPU and wall time, and no value echo. - docs/cli.md documents the non-TTY behavior. Refs HACK-1385 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi
marked this pull request as ready for review
October 24, 2026 02:42
This was referenced Oct 24, 2026
roodboi
added a commit
that referenced
this pull request
Oct 24, 2026
Follow-ups to #433 (HACK-1375) and #435 (HACK-1376), both already on `next`. ## Root cause 1. **The unset compare-and-swap was not tested.** `unsetProjectEnvValue` passes `expectedExisting: text` to the layer write. If you delete that line, every lock, hydrate and config test still passes, because only the set path had a paused-writer conflict test. 2. **Some layer locks can never be reclaimed, and the message did not say so.** `withEnvLayerLock` reuses the projects-registry owner protocol. That protocol never removes a malformed receipt, a receipt it does not trust (another uid, or writable by group or others), a receipt whose PID now belongs to a live process, or a dead owner behind a leftover `<lock>.recovery` guard. In each of these cases every `env set/add/unset/hydrate` on the layer waited 10 s and then printed `Another hack env command is writing <file>; … Retry when it finishes.` No retry could ever succeed, and the message did not name the lock file. 3. **A key can become unignored after a later `git init`.** In a non-git directory, the first `--secret` creates the key and writes its root `.gitignore` entry. Outside git, an existing key deliberately triggers no `.gitignore` write. So if `.gitignore` is deleted and the directory then gets `git init`, the key is not ignored, and Hack is not running at that moment. 4. **The re-include refusal named the wrong cause.** The ignore probe that runs after the entry is in place produced the same "Hack could not add it to <.gitignore>" message as a failed write. That is wrong when `.gitignore` already contains `.hack.secret.key` followed by `!.hack.secret.key`. ## Fix - `src/lib/env-layer-lock.ts`: `EnvLayerLockTimeoutError` now has a `reason` (`held` | `recovery_guard`) and the `lockPath`. The message is still one value-free line with no stack: - `held`: `Another hack env command is writing <file>; gave up after waiting 10s for its lock at <lock>. Nothing was written. Retry when it finishes. If no hack env command is running, Hack cannot reclaim that lock itself: remove <lock> and retry.` - `recovery_guard`: when the registry reports a dead owner behind a busy guard, the message keeps the registry's `recovery guard is busy (interrupted recovery requires offline inspection)` detail and names both `<lock>.recovery` and `<lock>` as the files to remove. - The lock helper still never removes anything itself. `src/lib/projects-registry-lock.ts` is unchanged; the classification reads the registry's existing timeout detail. That file belongs to G73 / HACK-1377. - `src/lib/project-env-config.ts`: `projectEnvKeyUnignoredError` now takes a `cause`. `unwritable` keeps the existing message. `reincluded` says the root `.gitignore` lists the key but a later rule in that file re-includes it, and asks the user to remove that rule. Once the entry is present, no deeper ignore file can apply to a key at the project root, so a later rule in that same file is the only possible cause. - `src/commands/doctor.ts`: exports `checkProjectEnvKey` so the real doctor check can be tested end to end. It also moves a misplaced docblock onto that function. The check's behavior is unchanged. - `docs/env.md`: documents unreclaimable locks and the recovery step, the re-include cause, and the `git init` decision. It also adds an `env-key-unignored` example to the doctor table. ### Decision on a later `git init` (item 3) Watching for `git init` is out of scope for env writes, because Hack runs no code when it happens. Outside git, an existing key still needs no entry, and Hack does not recreate a root `.gitignore` the user deleted. That behavior is unchanged and covered by the existing test. The guarantee comes back at the next Hack touchpoint: - `hack doctor`'s env key check reports `env-key-unignored` (warn) with a remedy. This check already existed; the new test proves it catches this exact sequence. - The first `--secret` set or hydrate in the new repository adds the entry before it uses the key, or it refuses (#433 behavior). ## Tests (each proven by mutation) | Test | Mutation that makes it fail | | --- | --- | | `project-env-layer-lock`: *an unset refuses with a conflict when a writer that takes no lock changed the layer after its read*. A `spyOn` pauses `resolvePrimaryLocalProjectDir`, the one awaited call between the local unset's read and its write. The test edits the layer, resumes, and expects `AtomicWriteConflictError` with the edit kept. | delete `expectedExisting: text,` from the unset: 1 fail | | `project-env- layer-lock`: *a layer lock Hack cannot reclaim…* uses three receipts: malformed, group-writable with an exited PID, and PID 1 standing in for a reused PID. Set and unset each refuse with the exact message, the receipt is left untouched, and removing the named lock lets the retry succeed. The existing held-lock test asserts the new message too. | restore the old message: 2 fail | | `project-env-layer-lock`: *a dead owner behind an interrupted recovery guard…* covers a set (300 ms wait) and a hydrate (default 10 s): both give the `recovery_guard` message with both paths, write nothing, and leave the lock and guard in place. After both are removed, the set succeeds. | classify the guard case as `held`: 1 fail | | `project-env-key-ignore`: the re- include test now expects the `reincluded` message, does not contain "could not add it", and checks that `.gitignore` was not rewritten. | send `unwritable` for the post-entry probe: 1 fail | | `project-env-key-ignore`: *a key whose entry was removed before a later git init…* runs the exact reported sequence through the source CLI, then the real `checkProjectEnvKey`: status `warn`, `env-key-unignored`, and no key bytes or absolute root in the output. The next `--secret` set repairs the entry and doctor returns `ok`. | drop `inspectLocalKeyIgnored` from doctor: 1 fail | Every fixture uses a private HOME, HACK_HOME, global config and Docker config, `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`, and git without global or system config. The env layer is written before any `--secret`. The in-process doctor call swaps in the same private environment and restores it afterwards. ## Evidence - Manual repro before the fix: (2) dead owner plus guard, and a malformed receipt, gave the old "Retry when it finishes." message. (3) after `git init`, `git check-ignore` exits 1 and `git status` lists `.hack.secret.key`. (4) gave the "could not add it" message. After the fix, the same script prints the messages above, and removing the named lock makes the retry exit 0. - Focused: `tests/project-env-layer-lock.test.ts` 12/12 and `tests/project-env-key- ignore.test.ts` 8/8 pass locally. - `bun run check` (includes `privacy:check`) exit 0. `bun run --cwd packages/cli typecheck` exit 0. gitleaks 8.30.1 (`gitleaks dir` on the `git log -p origin/next..HEAD` export): no leaks. - The CLI surface and consumer agent guidance did not change, so no reference or plugin regeneration was needed. - Full suite: hosted CI `test` at this PR head (see checks). ## Gaps - In a wider local run of 11 related env/doctor files, 13 tests in `project-env-write-refusals`, `project-env-stable-ciphertext` and `project-env-worktree-key` failed with `beforeEach/afterEach hook timed out` while the host load average was 51–60 on 18 cores. None of them asserts a message this PR changes. Hosted CI is the evidence for them. - The refusal is still a `ClosedCliError` with no `E_*` code (as before). Adding a lock-busy code would change the error-code registry and its generated docs, so it is left for a separate decision. - Classifying `recovery_guard` depends on the registry's existing timeout text. If G73 rewords that text, the guard test fails in the merge train rather than silently falling back to `held`. - The `held` message cannot tell a live writer from an unreclaimable lock without parsing the receipt (code G73 is editing), so it covers both cases: retry, or remove the lock when no hack env command is running. - The unset CAS test uses `spyOn` on an ESM namespace export (`worktree-local-config.ts`), in process and restored in `finally`. ## Round 2: merge with current next While this PR was being verified, `next` moved from 7d9c89f to 5208ee5 (#404, #446, #430, #439, #421, #448, #452, #405), and the PR became `CONFLICTING`. - Merge commit 7ac7cbb merges `origin/next` (5208ee5) into the branch. The only conflict was the `checkProjectEnvKey` docblock in `src/commands/doctor.ts`, which #448 also moved. It is resolved to this PR's side: the extended docblock plus `export async function checkProjectEnvKey`. Compared with next, `doctor.ts` now differs only in those lines. - No other code changed in this round. The diff against next is still the same 6 files. - Gates on the merged head: `bun run check` (includes `privacy:check`) exit 0. `bun run --cwd packages/cli typecheck` exit 0. Focused tests `project-env-layer-lock`, `project-env-key- ignore` and `cli-spawn-env-isolation` pass 27/27 (`--timeout 120000`) under #405's new preloads. gitleaks 8.30.1 (`gitleaks dir` on the `git log -p origin/next..HEAD` export): no leaks. - Full suite: hosted CI run 40056707145 at 7ac7cbb (base 5208ee5): `test` passed in 38m45s, plus docker-e2e 1-4/4, Candidate core (ubuntu), Config compiler (ubuntu x64/arm, macos-15-intel), Runtime state models, linux-process-lifetime, runtime-images and secret-scan. The two `macos-latest` jobs were still queued for a runner after about an hour. The earlier green run 39978726682 was at ed877f5, before the base moved, so it does not count for this head. Refs HACK-1386 🤖 Generated with [Claude Code](https://claude.com/claude-code)
roodboi
added a commit
that referenced
this pull request
Oct 24, 2026
…yes, and test every prompt guard (#466) Follow-ups to #446 (HACK-1385) from its verifier. ## Root cause `@clack/prompts` opens a terminal readline over stdin. On a piped stdin it never settles and spins at full CPU. #446 put `canPrompt()` guards in front of the known prompt sites, but three gaps were left: 1. **The per-site guards had no regression test.** Replacing the `mcp` guard with a no-op passed every existing test, while `echo | hack mcp install` spun. 2. **`hack ssh` and `hack node pair walkthrough` called clack with no guard.** `hack ssh --direct` and `hack ssh --host <h>` were confirmed to spin on a piped stdin (killed at the bound with the prompt rendered). The `node` walkthrough gated its prompts on `isTty()`, which checks stdout only, so a terminal stdout with a piped stdin still reached `text`/`confirm`. Its "Execute remote approval now?" confirm had no gate at all. 3. **`hack secrets delete <name>` could not be scripted.** Its `fail` policy refused every run that could not prompt, and no flag confirmed the deletion. ## Fix - `src/commands/ssh.ts`, each site behind `canPrompt()`: - Connection method: refuses with `E_INTERACTIVE_REQUIRED`, naming `--tailscale` and `--direct --host <host>`. - Direct host: refuses, naming `--host`. - Follow-up "What would you like to do?": answers "Done", because the connection info is already printed and the workspace can be passed as the positional argument. - "Turn on Tailscale?": declines with a note. It never runs `tailscale up` without an answer. - `src/commands/node.ts`: - The walkthrough input prompts gate on `canPrompt()` instead of `isTty()`. - The execute-now confirm is skipped without a TTY. The session is left pending, using the existing "complete manually" path. - Other `isTty()` uses (passing `interactive` to the ssh bootstrap) are not clack prompts and are unchanged. - `src/commands/secrets.ts`: `hack secrets delete <name> --yes` confirms the deletion explicitly. Without `--yes`, a run that cannot prompt still refuses closed with `E_INTERACTIVE_REQUIRED` before the keychain is touched, and the hint now names `--yes`. A TTY run without `--yes` still asks, with default no. - `src/commands/session.ts`: `promptAttachedWorkspaceAction` is exported, with a doc comment, so its guard can be tested directly. In a non-TTY spawn the picker refuses first, so this guard is unreachable through the CLI. - Docs: `docs/cli.md` lists `secrets delete --yes` and `ssh --host` among the scriptable forms. `docs/reference/cli.md` is regenerated (`bun run docs:cli-reference`) for the new option and description. The consumer agent guidance does not mention these commands, so the plugin bundle is unchanged. ## Tests `tests/no-tty-prompt-sites-cli.test.ts` (new) has 14 cases. Each spawns a real child with `privateCliEnv` and a stdin that is piped and closed at once, and sets no `HACK_NO_INTERACTIVE`. The child also gets a private `HACK_HOME`, `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1` and `HACK_SESSIONS_MUX=none`, plus a SIGKILL bound of 10 s and a 30 s test timeout. Each case asserts no signal, wall time under the bound, no clack `◆` prompt rendered, and the policy outcome: | Site | Command | Expected | | --- | --- | --- | | mcp `resolveTargets` | `mcp print` | `E_INTERACTIVE_REQUIRED` naming `--all`/`--cursor`/`--claude` | | session picker | `session` (one registered project) | refusal naming `hack session list` / `attach <name>` | | session attached workspace | worker `tests/fixtures/session-attached-prompt-worker.ts` | refusal naming `--new` | | update `confirmInstall` | `update --json` with a fetch-stub preload (release v999) and a stdout-TTY preload | `{"ok":false,"error":"Non-interactive shell; re-run with --yes …"}`; the stand-in binary is untouched | | secrets name | `secrets get` | refusal; the `Bun.secrets` stub log is empty | | secrets value | `secrets set <name>` | refusal; stub log empty | | secrets delete, no `--yes` | `secrets delete <name>` | refusal naming `--yes`; stub log empty | | secrets delete `--yes` | `secrets delete <name> --yes` (plus `HACK_NO_INTERACTIVE=1`) | exit 0, `Deleted secret`, stub log `delete hack-cli <name>` | | agent client picker | `agent init --path <repo>` | exit 0, prompt printed, no picker | | ssh method | `ssh` | refusal naming `--tailscale`/`--direct`/`--host` | | ssh host | `ssh --direct` | refusal naming `--host` | | ssh follow-up | `ssh --host <h>` | exit 0, "Copy the SSH command above" | | node walkthrough input | `node pair walkthrough` with a stdout-TTY preload | exit 1, `Missing --source` | | node execute-now confirm | `node pair walkthrough --source … --endpoint <local Bun.serve>` | exit 0, "Session left pending" | New fixtures: - `tests/fixtures/secrets-store-stub-preload.ts`: a `Bun.secrets` stub, so no case reaches the OS keychain, even under mutation. - `tests/fixtures/release-fetch-stub-preload.ts`: answers only the GitHub latest-release URL. Any other fetch throws, so nothing is downloaded. - `tests/fixtures/stdout-tty-preload.ts`: a terminal stdout with a piped stdin. It proves the stdin half of `canPrompt()`. `tests/global-command.macos.test.ts` now counts calls that reach the mocked clack `confirm`. Two TTY-less cases (stdin and stdout `isTTY=false`, no opt-out) cover the two policies global commands use: - **accept-default:** the `global trust` keychain case is now `test.each` over `--no- interactive` and no-TTY. Both assert the sudo keychain install runs and `confirm` is never called. - **decline:** `global authorize` without a TTY skips the sudoers install, although the prompt's default is yes, and never calls `confirm`. - No global command uses the `fail` policy. `fail` without a TTY is covered by the secrets cases above and by the existing `tests/interactivity.test.ts` and `tests/env-unset-no-tty- cli.test.ts`. ### Mutation proof Each guard was mutated alone: the guard was made a no-op, or the original gate was restored where one existed. Then only its test was run. All 17 mutations were caught: | Mutation | Result | | --- | --- | | mcp, session picker, session attached, secrets name/value, agent, ssh method/host/follow-up guards → `if (false)` | each test fails: child SIGKILLed at 10 s | | update `isTty: canPrompt()` → `true` | fails: SIGKILL | | update → `process.stdout.isTTY === true` (the pre-#446 gate) | fails: SIGKILL | | node input `canPrompt()` → `isTty()` (the old gate) | fails: SIGKILL | | node execute-now confirm guard removed | fails: SIGKILL | | secrets delete `--yes` ignored | `--yes` test fails (exit 1, expected 0) | | secrets delete `--yes` always on | refusal test fails (exit 0, expected 1) | | `confirmSafe` gate → `!isExplicitlyNonInteractive()` (pre-#446) | both new global-command cases fail; the other 31 still pass | ## Evidence - Bun 1.4.2 (`mise x bun@1.4.2`), `bun install --frozen-lockfile`, no `bun.lock` change. - `bun run --cwd packages/cli typecheck`: 0 errors (covers `tests/`). - Root `bun run check`, which includes `privacy:check`: 2/2 tasks successful, `privacy-check: ok`. - `ultracite check` on every changed file: clean. - Focused run of 20 files: 181 pass, 0 fail (183 tests). The files are the new test, `cli-spawn- env-isolation`, `global-command.macos`, `env-unset-no-tty-cli`, `interactivity`, `ssh- command`, the `session-*` files, `node-pair-command`, `node-workspace-command`, `node-attach- instructions`, `cli-per-command-options`, `cli-reference-docs`, `update-command`, `mcp- install`, `mcp`, `agent-onboard-command` and `doctor-fix-noninteractive`. - gitleaks 8.30.1 over the exported `origin/next..HEAD` range: no leaks. - Before the fix, `echo | hack ssh --direct` and `echo | hack ssh --host example.test` were killed at a 6 s / 30 s bound with the clack prompt rendered. After the fix, both settle in about 1–3 s (CLI cold start) with the policy outcome above. - Full suite: hosted CI `test` job at the exact PR head `3a3efd24` passed: 8109 pass, 0 fail (8266 tests across 575 files, including `tests/no-tty-prompt-sites-cli.test.ts`; run 40057772445). The docker-e2e shards 1–4, the ubuntu Candidate core and Config compiler jobs, runtime models, linux-process-lifetime, runtime-images and secret-scan also passed. The macOS Candidate core and Config compiler jobs were still queued for a runner when this was written. ## Gaps - The session attached-workspace guard is tested through a worker that imports the exported function. In a spawned non-TTY CLI the picker guard always fires first, so this guard is defense in depth. - `node.ts` still passes `interactive: isTty()` to the ssh key bootstrap (`ensureNodePairSshReady`). That path runs `ssh` with an inherited stdin, not a clack prompt, so it is out of scope here. `ssh`/`node` are unsupported surfaces (AGENTS.md); only their clack prompt sites were guarded. - Each CLI spawn takes about 2.5–3 s here (cold module graph), so the new file takes about 40 s. The 10 s kill bound leaves headroom for slower hosted runners. - No `fail`-policy `confirmSafe` site exists in `global` commands, so `global- command.macos.test.ts` covers decline and accept-default only. Refs HACK-1410 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause
hack env unset KEYwith stdin/stdout not a TTY and withoutHACK_NO_INTERACTIVE=1never returned and burned a full CPU core (measured 99% CPU, 3.1 s CPU in 3 s wall, killed only by an external timeout).confirmSafeinsrc/lib/interactivity.tsonly consulted the explicit opt-out (--no-interactive/HACK_NO_INTERACTIVE). A TTY-less run without that opt-out fell into the "legacy clack behavior" branch and entered@clack/promptsconfirm, whosePrompt.prompt()opens areadlineinterface withterminal: trueover stdin and waits for keypress events. On a piped stdin that loop never settles and re-renders continuously.canPrompt()(TTY on both streams plus the opt-outs) already existed and the rawtext/password/selectsites inenv.tswere guarded with it; the shared confirm helper was the one path that was not.Fix
confirmSafegates oncanPrompt(). Whenever prompting is disallowed (no TTY on stdin or stdout,HACK_NO_INTERACTIVE,--no-interactive) the declared policy resolves the answer within milliseconds:accept-defaultapplies the documented default,declineanswers no,failthrowsE_INTERACTIVE_REQUIREDwith the scriptable hint. The clack prompt is never entered without a TTY (documented as an invariant on the helper).doctorConfirmprints its "skipped destructive step" note on the same condition (!canPrompt()), not only under the explicit opt-out.@clack/promptscall site (confirm/select/text/password/multiselect/autocompleteMultiselectanddoctorConfirm); sites with the same latent loop are routed through the convention:secrets: name prompt, value prompt and delete confirmation (nowconfirmSafewithfail) refuse closed without a TTY, before the keychain is touched.session: the default picker and the attached-workspace choice refuse withE_INTERACTIVE_REQUIREDnaminghack session list/hack session attach <name>/--new.mcp: target selection refuses, naming--all/--cursor/--claude/--codex.update: the install confirmation checkscanPrompt()(stdin as well as stdout; the previousprocess.stdout.isTTYcheck let a piped stdin spin).agent: the client picker usescanPrompt()(already short-circuited on rawisTTY; now also honors the opt-outs).env.tsraw prompts,project.tsinit prompts (behind theio.canPrompt()seam),setup.ts,init-agent-integrations.ts(interactiveflag),native-domain-dns-authorization.ts(usesconfirmSafe).docs/cli.md(agent/scripted ergonomics): documents that a run whose stdin or stdout is not a TTY resolves every prompt by its non-interactive policy, withenv unsetanddoctor --fixas examples of safe defaults andupdate --yes/mcp --all/session attach <name>as examples of closed refusals.No CLI options or help text changed, so
docs/reference/cli.mdand the agent plugin bundle are unchanged.Tests
tests/interactivity.test.ts: three new cases stubstdin.isTTY=false(stdout true, no opt-out) and assertaccept-defaultreturns the default within 1 s,declinereturns false, andfailthrowsE_INTERACTIVE_REQUIREDmentioning "no TTY" and the hint. Each is bounded by a 2 s test timeout.tests/env-unset-no-tty-cli.test.ts(new): spawns the real CLI with piped stdin, noHACK_NO_INTERACTIVE, private HOME/HACK_HOME/DOCKER_CONFIG, the env layer file written first,HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1, and a 5 s SIGKILL bound. Assertsenv unsetexits 0, removes the key, prints no prompt and never prints the value (canary), with wall time < 5 s and CPU time < 3 s (proc.resourceUsage()); and thatsecrets delete <name>(afail-policy site) exits 1 withE_INTERACTIVE_REQUIREDnaming the way out, before any keychain access.!isExplicitlyNonInteractive()) makes all five new tests fail (the three unit cases time out at 2 s; both e2e cases are SIGKILLed at ~5.0 s with the key still present). Re-runningtests/global-command.macos.test.tsunder that mutation passes 31/31, which proves the 12 failures it showed under the fix came from the fix, not a pre-existing flake.tests/global-command.macos.test.ts: its clackconfirmmock stands in for an interactive user, andbun testhas no TTY, so the file now stubs both streams'isTTYto true per test (restored inafterEach, reusing itsrestoreIsTtyhelper). The one test that exercises the non-interactivesudo -nfallback pins stdin back to non-TTY.Evidence
mise x bun@1.4.2),bun install --frozen-lockfile, nobun.lockchange.f0c217a1):bun run --cwd packages/cli typecheck0 errors; rootbun run check(includesprivacy:check) 2/2 tasks successful, 0 errors (34 pre-existing warnings); focused run of 17 test files covering every touched command and every file that mocks clack: 188 pass, 0 fail (191 tests, 3 skipped) after the test-fixture adjustments above.origin/next..HEADrange: no leaks.echo "" | bun index.ts env unset DOOMEDin a temp project: before, killed at 5 s with the prompt rendered and the key still present; after, exit 0 in 0.21 s wall / 0.27 s CPU with the key removed.Gaps
src/commands/ssh.tsandsrc/commands/node.tsstill call clackselect/text/confirmwithout acanPrompt()guard. Both are unsupported remote/node surfaces perAGENTS.md, so they were left untouched; reported as findings.env unsetis the documented default (the key is removed, matchingHACK_NO_INTERACTIVE=1), not a refusal; there is no--yes/--forceonenv unsetand none was added.Bun.Subprocess.resourceUsage()and is skipped if the runtime returnsundefined; the wall-time and signal assertions still fail a regressed prompt path.Refs HACK-1385
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need.