Fail fast on interactive prompts in non-interactive shells#209
Conversation
Interactive pterm prompts (confirm/select/text input) never return when stdin is not a terminal: the underlying keyboard listener swallows read errors and spins forever, re-rendering the prompt in a tight loop. AI agents and CI scripts that invoke a prompting command hang until killed (verified: 'kernel create < /dev/null' produced ~13MB of ANSI output in 10s and never exited). Every prompt call site is now gated on a stdin TTY check and returns a specific, actionable error instead: - Confirmation prompts (all delete commands, the browsers-create pool conflict confirm, and the create overwrite confirm) direct the caller to re-run with --yes. - Text/select prompts (kernel create name/language/template, browsers create --viewport-interactive) name the exact flag and valid values to pass instead. New in this change: - pkg/interactive: IsInteractive() plus the two shared error builders. - kernel create --yes/-y: skip the overwrite-existing-directory confirm (the only confirm that had no --yes bypass, along with the pool conflict confirm in browsers create, which also gains --yes). - Invalid --name/--language/--template values now error in non-interactive shells instead of silently falling back to a prompt. kernel login, browsers ssh, and upgrade are intentionally left alone; they are usable by agents as-is.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Create command wraps prompt errors
- Updated runCreateApp to return PromptForAppName/Language/Template errors directly (without wrapper prefixes) and added a command-level regression test to enforce the contract.
Or push these changes by commenting:
@cursor push 98875e9f2c
Preview (98875e9f2c)
diff --git a/cmd/create.go b/cmd/create.go
--- a/cmd/create.go
+++ b/cmd/create.go
@@ -152,17 +152,17 @@
appName, err := create.PromptForAppName(appName)
if err != nil {
- return fmt.Errorf("failed to get app name: %w", err)
+ return err
}
language, err = create.PromptForLanguage(language)
if err != nil {
- return fmt.Errorf("failed to get language: %w", err)
+ return err
}
template, err = create.PromptForTemplate(template, language)
if err != nil {
- return fmt.Errorf("failed to get template: %w", err)
+ return err
}
c := CreateCmd{}
diff --git a/cmd/create_test.go b/cmd/create_test.go
--- a/cmd/create_test.go
+++ b/cmd/create_test.go
@@ -10,6 +10,7 @@
"github.com/kernel/cli/pkg/create"
"github.com/pterm/pterm"
+ "github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -93,6 +94,61 @@
}
}
+func TestRunCreateApp_DoesNotWrapPromptErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ flagValues map[string]string
+ expectedContains string
+ unexpectedWrap string
+ }{
+ {
+ name: "missing app name returns direct prompt error",
+ flagValues: map[string]string{},
+ expectedContains: "cannot prompt for app name",
+ unexpectedWrap: "failed to get app name:",
+ },
+ {
+ name: "invalid language returns direct validation error",
+ flagValues: map[string]string{
+ "name": "my-app",
+ // template is intentionally omitted because language validation fails first.
+ "language": "ruby",
+ },
+ expectedContains: "invalid --language 'ruby'",
+ unexpectedWrap: "failed to get language:",
+ },
+ {
+ name: "invalid template returns direct validation error",
+ flagValues: map[string]string{
+ "name": "my-app",
+ "language": "typescript",
+ "template": "nonexistent-template",
+ },
+ expectedContains: "invalid --template 'nonexistent-template'",
+ unexpectedWrap: "failed to get template:",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cmd := &cobra.Command{}
+ cmd.Flags().String("name", "", "")
+ cmd.Flags().String("language", "", "")
+ cmd.Flags().String("template", "", "")
+ cmd.Flags().Bool("yes", false, "")
+
+ for flag, value := range tt.flagValues {
+ require.NoError(t, cmd.Flags().Set(flag, value))
+ }
+
+ err := runCreateApp(cmd, nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.expectedContains)
+ assert.NotContains(t, err.Error(), tt.unexpectedWrap)
+ })
+ }
+}
+
// TestAllTemplatesWithDependencies tests all available templates and verifies dependencies are installed
func TestAllTemplatesWithDependencies(t *testing.T) {
if testing.Short() {You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit f722ba2. Configure here.
Addresses Bugbot review on #209: runCreateApp wrapped the fail-fast prompt errors with 'failed to get app name/language/template' prefixes, so CLI output didn't match the documented non-interactive messages and was inconsistent with the unwrapped overwrite-confirm error. Return them directly and add a regression test.
Previously kernel create failed on the first promptable input it encountered (--name, then --language, then --template), so a non-interactive caller needed one retry per missing flag. In a non-interactive shell, all inputs are now validated up front via create.ValidateNonInteractive and every problem is reported in a single error, including an existing target directory that would need --yes: Error: cannot prompt for input: stdin is not an interactive terminal; fix all of the following and re-run: (1) --name is required (e.g. --name my-kernel-app); (2) --language is required: one of: typescript (ts), python (py); (3) --template is required (run 'kernel create --help' for the full list) Problems are numbered inline rather than newline-separated because the fang/lipgloss error renderer reflows text and collapses line breaks. A template that exists for no language is reported even when --language is also invalid, to avoid a second retry. The per-prompt guards remain as backstops for the interactive flow.
|
f525658: non-interactive errors for Details:
|
masnwilliams
left a comment
There was a problem hiding this comment.
The core fail-fast behavior works, but this needs structural changes before merge. The current design leaves the no-hang invariant as a scattered call-site convention, forks kernel create into separate interactive/non-interactive validation pipelines, and tests the raw error rather than the rendered CLI boundary. No file crosses the 1k-line threshold in this PR, but this adds more policy branching to the already 3,838-line cmd/browsers.go.
make test passes and /dev/null smoke tests exit immediately, but the blockers below keep this below the maintainability bar.
| // Interactive prompts (pterm confirm/select/text input) read keystrokes from | ||
| // the terminal. In a non-interactive shell — an AI agent's bash tool, CI, or | ||
| // any piped stdin — they never return (the underlying keyboard listener spins | ||
| // forever), so every prompt call site must gate on IsInteractive first and |
There was a problem hiding this comment.
blocking: this makes the critical invariant — every prompt must be gated — an unenforced call-site convention. There are now 15 prompt invocations spread across 12 files, so one future direct pterm prompt can recreate the hang. This package should own the actual confirm/select/text execution, with command code supplying only the action, hint, and options; direct interactive pterm calls should not remain outside that boundary.
There was a problem hiding this comment.
Fixed in ba28842. pkg/interactive now owns prompt execution: Confirm/Select/TextInput run the pterm prompts behind the TTY gate, and command code supplies only the action, hint, and options. Zero pterm.DefaultInteractive* calls remain outside the package (grep-verified). Two side benefits: prompt errors are no longer silently dropped (ok, _ :=), and the confirms stop mutating pterm's global DefaultInteractiveConfirm. Happy to add a forbidigo rule banning pterm.DefaultInteractive outside pkg/interactive as mechanical enforcement, but the repo has no golangci config today and make lint is || true, so I left that for a separate change.
| } | ||
| fmt.Fprintf(&b, "(%d) %s", i+1, p) | ||
| } | ||
| return fmt.Errorf("cannot prompt for input: stdin is not an interactive terminal; fix all of the following and re-run: %s", b.String()) |
There was a problem hiding this comment.
blocking: this is tested below the real rendering boundary and the documented actionable output does not survive it. Running kernel --no-color create </dev/null renders the third problem as (3) -- followed by template is required on the next line, so final stderr does not contain --template. The substring assertions against err.Error() cannot catch that. Please use a typed multi-problem error with explicit rendering (or otherwise preserve tokens), and add an end-to-end assertion against the final CLI stderr.
There was a problem hiding this comment.
Fixed in ba28842 — good catch, and the root cause was worse than wrapping: fang's ErrorText style applies a Transform that does strings.Fields + Join(" ") (collapsing any newlines) and a width-based word-wrap that breaks on hyphens (producing the -- / template split you saw).
Now there's a typed interactive.PromptError carrying Problems []string: Error() stays single-line per Go convention (numbered inline), and Display() renders one problem per line. The root error handler errors.As-matches it and renders Display() with UnsetWidth().UnsetTransform(), so flag tokens are never split:
ERROR: Cannot prompt for input: stdin is not an interactive terminal; fix all of the following and re-run:
- --name is required (e.g. --name my-kernel-app)
- --language is required: one of: typescript (ts), python (py)
- --template is required (run 'kernel create --help' for the full list)
The new e2e test (TestCreateNonInteractiveE2E) asserts against final rendered CLI stderr from a real subprocess: exit 1, every flag token intact, and each problem starting its own line.
| // In a non-interactive shell, validate every input up front so a single | ||
| // error reports everything the caller must fix, instead of failing on one | ||
| // missing input per invocation. | ||
| if !interactive.IsInteractive() { |
There was a problem hiding this comment.
blocking: this forks create into two resolution pipelines. ValidateNonInteractive separately owns normalization/validation and even cwd-dependent os.Stat, while the interactive path continues through PromptForAppName / PromptForLanguage / PromptForTemplate. Every future field or validation rule now has multiple owners and can drift. Please collapse this into one canonical resolver from raw flags to normalized CreateInput plus typed problems; interactive mode can resolve those problems through a prompter, while non-interactive mode renders the same problem model.
There was a problem hiding this comment.
Fixed in ba28842. There is now one canonical resolver: create.ResolveInput(name, language, template, skipConfirm) → normalized CreateInput + typed []Problem (fields: name, language, template, overwrite — including the cwd-dependent dir check). Both modes consume the same problem model:
- Interactive:
runCreateApploops — resolve, prompt for the first remaining problem's field, re-resolve — so the template list always reflects the resolved language, and invalid provided values surface the resolver's message as a warning before the prompt. Prompts (PromptName/PromptLanguage/PromptTemplate/PromptOverwrite) are thin per-field wrappers overpkg/interactivewith no validation logic. - Non-interactive: the same
[]Problemrenders as a single fail-fast error viaErrInputsRequired.
ValidateNonInteractive and the validating PromptFor* pipeline are deleted; every field/rule now has exactly one owner. Verified the interactive flow end-to-end under a real PTY (name → language select → template select → scaffold, plus the invalid-value warn-then-reprompt path).
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // Under `go test` stdin is not a terminal, so any command path that would |
There was a problem hiding this comment.
blocking test isolation: these tests do not arrange the condition they claim to test; they rely on ambient stdin being non-TTY. Running the compiled test binary under a PTY sends TestCreateFailsFastAggregatedWhenNonInteractive into the pterm prompt and it times out — the test can reproduce the exact hang this PR prevents depending on the harness. Inject the terminal capability, test the detector with an explicit pipe/file descriptor, and keep one CLI subprocess test with stdin explicitly set to /dev/null.
There was a problem hiding this comment.
Fixed in ba28842, on all three counts:
- Injected capability:
interactive.ForceTerminal(isTTY) (restore func())swaps the package's terminal check; every gating test (cmd + pkg/create + pkg/interactive) arrangest.Cleanup(interactive.ForceTerminal(false))explicitly instead of relying on ambient stdin, so the suite behaves identically under a PTY harness. - Detector tested against an explicit fd:
TestTerminalCheckReportsFalseForPipeasserts the real detector on both ends of anos.Pipe(). - Subprocess CLI test:
TestMainre-execs the test binary as the real CLI (cmd.Execute) whenKERNEL_CLI_E2E_EXEC=1;TestCreateNonInteractiveE2Eruns it with stdin explicitly opened from/dev/null, a 30s context timeout (so a regression to the hang fails the test instead of wedging the suite), and asserts exit code 1 plus intact tokens in the final rendered stderr.
Addresses masnwilliams' blocking review on #209: 1. pkg/interactive now owns prompt execution. Confirm/Select/TextInput run the pterm prompts behind the TTY gate; command code supplies only the action, hint, and options. No direct pterm interactive calls remain outside the package, so the no-hang invariant is enforced by the boundary rather than a per-call-site convention. Prompt errors are no longer silently dropped, and the confirm primitives stop mutating pterm's global DefaultInteractiveConfirm. 2. Typed PromptError with explicit rendering. Error() stays a single line (Go convention, numbered problems); Display() renders one problem per line. The root error handler renders PromptError with fang's ErrorText width and transform unset: the width-based word-wrap split flag tokens ('--' + newline + 'template') and the transform (strings.Fields + Join) collapsed newlines. Rendered stderr now contains every flag token intact. 3. One canonical resolver for kernel create. create.ResolveInput is the single path from raw flags to a normalized CreateInput plus typed Problems (name, language, template, overwrite). The interactive flow prompts to resolve one problem at a time and re-resolves; the non-interactive flow reports the same problem model in a single fail-fast error. The forked ValidateNonInteractive/PromptFor* pipelines are gone; prompts are thin per-field wrappers with no validation logic. 4. Test isolation. Gating tests inject the terminal capability via interactive.ForceTerminal instead of relying on ambient stdin, the detector is tested against an explicit pipe fd, and a subprocess e2e test (TestMain re-exec) runs the real CLI with stdin=/dev/null and asserts on final rendered stderr: exit 1, intact flag tokens, one problem per line. Interactive flows verified end-to-end under a real PTY (name -> language select -> template select -> scaffold; invalid-value warning before re-prompt).
|
@masnwilliams all four blockers addressed in ba28842 (replies inline on each thread):
Re: |
masnwilliams
left a comment
There was a problem hiding this comment.
The previous four blockers are substantively addressed: prompt execution is centralized, rendered stderr preserves flag tokens, create has one resolver/problem model, and the new subprocess test covers the real /dev/null boundary. make test, targeted race tests, shuffled repeats, and the PTY harness all pass.
Two maintainability blockers remain in the follow-up: the claimed terminal injection is actually a process-global production test hook, and the create problem dispatcher can silently spin forever on an unhandled field.
| // ForceTerminal overrides terminal detection for tests, making IsInteractive | ||
| // report isTTY regardless of the ambient stdin. It returns a function that | ||
| // restores the previous behavior; callers should register it with t.Cleanup. | ||
| func ForceTerminal(isTTY bool) (restore func()) { |
There was a problem hiding this comment.
blocking: this is not isolated dependency injection; it is an exported, process-wide mutable test switch. Two parallel tests that need opposite terminal states race and contaminate one another, and production code can change the terminal policy globally. That keeps the suite serial by convention and exposes test machinery in the runtime API. Please carry the capability on a prompter/context (or rely on subprocess tests with explicit stdin) so each invocation owns its terminal state without mutating package state.
There was a problem hiding this comment.
Fixed in 954f364. ForceTerminal and the mutable terminalCheck package variable are gone. The capability now lives on interactive.Prompter: the zero value / NewPrompter() detects from stdin at prompt time (immutable, read-only), and NewPrompterWithTerminal(false) gives a test a fixed capability scoped to that value — opposite terminal states coexist across parallel tests (the pkg tests are now t.Parallel() and pass with -race -shuffle=on -count=2), and nothing in the runtime API can change terminal policy globally.
Confirm/Select/TextInput are Prompter methods; command structs that confirm carry an injected prompter field (zero value = ambient, so no construction sites changed), and the package-level helpers remain only as delegation to the default Prompter for plain-function commands — those paths are covered by the subprocess test with explicit /dev/null stdin. Verified the compiled test binaries pass under a PTY harness (script -qec cmd.test ... → all green).
| } | ||
| v, err := create.PromptName() | ||
| if err != nil { | ||
| return err |
There was a problem hiding this comment.
blocking: this dispatcher has no failure path for an unknown Problem.Field. If ResolveInput ever adds or accidentally emits a field that is not handled here, the loop makes no state change and spins forever — exactly the failure class this PR is meant to remove. At minimum, add a default that returns an internal error naming the unsupported field; ideally make the resolver/dispatcher contract exhaustive by construction.
There was a problem hiding this comment.
Fixed in 954f364, taking the 'ideally' path: the field switch is deleted. ResolveInput now emits every Problem with its own interactive resolution step (a resolve closure that prompts for the field and applies the answer to RawInput), so a Problem cannot exist without a handler — the dispatcher just calls problems[0].Resolve(prompter, &raw) and re-resolves. Exhaustive by construction, with two backstops:
Problem.Resolvereturns an internal error naming the field if the step is somehow nil (hand-constructed Problem), rather than letting the loop spin.TestEveryProblemCarriesResolutionStepdrivesResolveInputacross raw-input shapes that produce all four fields (missing, invalid, per-language template, overwrite) and asserts each emitted problem carries its step.
Problem.Invalid now expresses the warn-before-reprompt behavior instead of the cmd layer inspecting raw values. Interactive flow re-verified end-to-end under a real PTY (invalid-language warn → language select → template select → scaffold).
…ustive Addresses masnwilliams' second review round on #209: 1. No process-global test hook. ForceTerminal and the mutable terminalCheck package variable are gone. interactive.Prompter now carries the terminal capability per value: the zero value / NewPrompter() detects from stdin at prompt time, and NewPrompterWithTerminal(false) gives tests a fixed capability without mutating package state, so opposite terminal states coexist across parallel tests and nothing in the runtime API can flip terminal policy globally. Confirm/Select/TextInput are Prompter methods; the package-level helpers delegate to the default (ambient, immutable) Prompter for plain-function commands. Command structs that confirm (api-keys, auth connections, credentials, credential providers, extensions, profiles, proxies, create) carry an injected prompter field whose zero value preserves existing construction. 2. Exhaustive-by-construction problem dispatch. create.ResolveInput now emits every Problem with its own interactive resolution step (resolve closure applying the prompt answer to RawInput), so the cmd-side field switch is gone and a Problem cannot exist without a handler. Problem.Resolve also defends against a hand-constructed Problem with an internal error naming the field instead of letting the loop spin. Problem.Invalid replaces the raw-value inspection for the warn-before-reprompt behavior. Tests: gating tests inject NewPrompterWithTerminal(false) (no global state; marked parallel where possible), a regression test asserts every ResolveInput problem carries a resolution step across all field shapes, and the nil-step internal error is covered. Verified the compiled test binaries pass under a PTY harness, with -race -shuffle=on -count=2, and the interactive flow end-to-end under a real PTY (invalid-language warn -> language select -> template select -> scaffold).
|
@masnwilliams both follow-up blockers addressed in 954f364 (details inline):
Full |
masnwilliams
left a comment
There was a problem hiding this comment.
The remaining blockers are addressed. Terminal capability now lives on a per-value Prompter with no mutable package state, and create problems carry guarded resolution behavior so an unsupported problem returns an explicit internal error rather than spinning. The earlier prompt-ownership, rendering-boundary, single-resolver, and test-isolation fixes remain intact.
Verified locally: make test; targeted -race -shuffle=on -count=2 runs for the changed packages and command paths; compiled-test execution under a PTY; and real CLI /dev/null rendering. Good to merge.


Problem
Interactive pterm prompts (confirm / select / text input) never return when stdin is not a terminal. The underlying keyboard listener (
atomicgo.dev/keyboard) swallows both the "provided file is not a console" init error and the subsequent nil-file read errors, so it spins forever re-rendering the prompt.Empirically:
kernel create < /dev/nullproduced ~13 MB of ANSI output in 10 seconds at 100% CPU and never exited. Any AI agent or CI script that hits a prompting code path hangs until killed — and floods the agent's context with escape codes on the way down.Fix
pkg/interactiveowns every prompt.Confirm,Select, andTextInputexecute the pterm prompts behind a stdin TTY gate; command code supplies only the action, hint, and options. No direct pterm interactive calls exist outside the package, so the no-hang invariant is enforced by the boundary, not a call-site convention. In a non-interactive shell each primitive fails fast with a typed, actionable error:Confirmation prompts → direct the caller to
--yes:Gated:
api-keys/app/auth connections/credential-providers/credentials/deploy/extensions/profiles/proxies delete, thebrowsers createpool-conflict confirm, and thecreateoverwrite confirm.Text/select prompts → name the exact flag(s) and valid values to pass instead.
kernel create(the only command with multiple promptable inputs) resolves everything up front and reports all problems in a single error — one problem per line, flag tokens never split by terminal-width re-wrapping:An existing target directory (which would need the overwrite confirm) is folded into the same error as a
pass --yes ...item. Single-input prompts stay single-line:All errors exit non-zero (previously, cancelled confirms exited 0, and prompt errors were silently dropped via
ok, _ :=).Design
pkg/interactive(new):IsInteractive()+Confirm/Select/TextInputprompt primitives + typedPromptError.Error()is single-line (Go convention, problems numbered inline);Display()renders one problem per line. The root error handler rendersPromptErrorwith fang'sErrorTextwidth and transform unset — the width-based word-wrap split flag tokens (--+ newline +template) and the transform (strings.Fields+Join) collapsed newlines. Prompts execute through aPrompterthat carries its terminal capability per value (zero value = ambient stdin detection;NewPrompterWithTerminal(false)for deterministic tests) — no package-level mutable state. Command structs carry an injectedprompterfield; plain-function commands use the default Prompter.create.ResolveInput(new): the single canonical resolver from raw flags to a normalizedCreateInputplus typedProblems (name, language, template, overwrite). Both modes consume the same problem model: the interactive flow prompts to resolve one problem at a time and re-resolves (so the template list reflects the chosen language, and invalid provided values warn before re-prompting); the non-interactive flow reports every problem in one fail-fast error. A--templatethat exists for no language is reported even when--languageis also invalid, to avoid a hidden extra retry. Prompts inpkg/createare thin per-field wrappers with no validation logic, and every emittedProblemcarries its own interactive resolution step, so the resolver/dispatcher contract is exhaustive by construction (no field switch to drift; a step-less Problem fails with an internal error naming the field instead of looping).--yesstays the single skip-confirmation pattern (no--force/--confirmmixing; existing--forceflags all mean something semantically different and are untouched). Two confirms had no bypass and now gain one:kernel create --yes/-y— overwrite an existing directory without askingkernel browsers create --yes/-y— proceed past the pool-conflict confirm (yesadded topoolLeaseAllowedFlagsso it doesn't count as a conflicting flag itself)kernel createlong help to document the fail-fast behavior.Intentionally out of scope
kernel login,kernel browsers ssh, andkernel upgradeare left alone — agents can use them as-is. (Possible follow-up: a two-stepkernel loginthat prints the URL and confirms separately. Another possible follow-up: a forbidigo lint rule banningpterm.DefaultInteractive*outsidepkg/interactive; the repo has no golangci config today.)Testing
make testpasses. Gating tests inject the terminal capability (interactive.NewPrompterWithTerminal(false)) instead of relying on the harness's ambient stdin — no global state, parallel-safe (verified with-race -shuffle=on -count=2and by running the compiled test binaries under a PTY); the detector is tested against an explicitos.Pipe()fd.TestMainre-execs the test binary as the real CLI,TestCreateNonInteractiveE2Eruns it with stdin explicitly/dev/nullunder a 30s timeout and asserts on final rendered stderr — exit 1, every flag token intact, each problem on its own line.--yes; delete-gating tests assert the API is never called without confirmation.< /dev/nullsmoke tests of every gated path.golangci-lint).Note
Medium Risk
Touches many delete/confirm code paths and changes create’s input flow; behavior change is intentional but broad enough that a missed call site could still hang or skip confirmation handling.
Overview
Introduces
pkg/interactiveas the only place that runs pterm confirm/select/text prompts, gated on stdin being a TTY. When stdin is not a terminal, prompts returnPromptErrorwith actionable fixes (typically--yesfor confirmations or explicit flags/values for inputs) instead of hanging.Delete and confirm paths across api-keys, apps, auth connections, credentials, credential providers, deploy, extensions, profiles, and proxies now go through
Prompter.Confirm(or packageinteractive.Confirm) and propagate errors instead of ignoring them withok, _ :=.kernel createis reworked aroundcreate.ResolveInput: one resolver builds normalized input plus ordered **Problem**s for name, language, template, and overwrite. Interactive mode resolves one problem at a time and re-resolves; non-interactive mode returns every problem in a single error. Adds--yesfor overwrite without prompting and updates long help for fail-fast behavior.kernel browsers createadds--yesfor the pool-conflict confirm and routes viewport selection throughinteractive.Selectwith a non-interactive hint to use--viewport.The root error handler renders
PromptErrorviaDisplay()without fang’s width wrap/transform so flag tokens stay intact on separate lines.Tests cover injected non-TTY prompters, resolver aggregation, and an E2E subprocess of
kernel createwith/dev/nullstdin.Reviewed by Cursor Bugbot for commit 954f364. Bugbot is set up for automated code reviews on this repo. Configure here.