Skip to content

Wizard prompts take their printed default at EOF instead of hanging - #773

Merged
philcunliffe merged 7 commits into
masterfrom
fix/issue-772
Aug 17, 2026
Merged

Wizard prompts take their printed default at EOF instead of hanging#773
philcunliffe merged 7 commits into
masterfrom
fix/issue-772

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Issue #772 bundles four deferred findings of very different natures. This PR fixes one of them - the only one that is a reproducible bug rather than a design change or an explicitly-declined polish. The other three are left, with reasons below.

Correction (triage, 2026-08-14, after two review rounds). The original description claimed hyp init < /dev/null as the headline reproducer and listed it as fixed. Both review rounds disproved this and triage re-verified it empirically; the description below has been corrected in place. For the record:

  • What was believed: hyp init < /dev/null hung on the three prompts this PR fixes, and this PR ends that hang.
  • What is actually true: that run never reaches these prompts. With stdout piped it is refused at the non-TTY gate (src/core/commands/init.js:129, exit 2, help text printed). With a terminal stdout it fails one screen earlier, at the wizard's fork menu (src/core/cli/wizard/fork.js:386, legacyMenuPrompt, still a direct rl.question): the promise never settles, and the process either dies with exit 13 on an unsettled top-level await (when nothing else keeps the event loop alive) or hangs forever. That defect exists identically on master and is untouched by this diff.
  • What this PR actually fixes: the three walkthrough.js prompts, reached by a partially scripted wizard run (fork answered, stdin then dry) and by a terminal that drops mid-wizard. Verified by the regression test and by in-process probes in both review rounds and in triage.
  • What remains: the fork menu plus three siblings (hypaware-core/plugins-workspace/claude-account/src/index.js:167, src/core/cli/confirm.js:30, src/core/plugin_install/confirm.js:150), tracked in Follow-up: close the remaining rl.question EOF-hang class #783 together with the two decisions an owner must make there (exit code when the terminal dropped rather than the user choosing quit; where the shared queuedLineAsker helper lives).
  • Also corrected: the base moved under this PR when Onboarding language and flow fixes from first user-onboarding feedback #771 merged, flipping the overwrite confirm to [Y/n] default yes; the table and the EOF-answer list below now reflect the current base. Worth stating plainly: a stdin that dries up mid-wizard now completes the install unattended (config rewritten over a backup, backfill imported), exactly as four bare Enters would. That is LLP 0190's stated rule, recorded here so it is on the record rather than discovered.
  • The Testing and PR Onboarding language and flow fixes from first user-onboarding feedback #771 sections below were also refreshed against a fresh install at the current head.

Fixed: the EOF hang (issue item 3, first bullet)

node:readline/promises' rl.question() leaves its promise permanently unsettled when the input stream ends without a line. Three prompts in src/core/cli/walkthrough.js called it directly, so a stdin that can no longer answer hung the wizard forever instead of taking the default it had just printed:

prompt line (master) printed default
defaultOverwriteConfirmFactory (the one named in the issue) 316 Continue? [Y/n]
legacyConfirmSelectPromptFactory (the LLP 0190 defaults gate) 439 select [N]
legacyBackfillConsentPromptFactory 507 [Y/n]

This is reachable in normal use: interactive in commitWizardPickedConfig / runPickerWalkthrough is !opts.picks, not "stdin is a TTY", so a partially scripted wizard run whose input runs out (fork answered, stdin then dry at the express gate, the commit-point confirm and the backfill consent - for example printf '2\n' | hyp init with a terminal stdout) or a terminal that drops mid-wizard lands here. The failure was a hang - no error, no exit code, nothing to grep for. (A fully unanswered hyp init < /dev/null never gets this far; see the correction above.)

The same file already solves exactly this for the numbered picker with queuedLineAsker, whose JSDoc spells out both halves: resolve a pending ask as null on close, and seed closed from the stream's own readableEnded, because readline registers its end listener at construction and so an interface built over an already-ended stream never emits close at all. All three prompts now read through that helper.

null is coalesced to the empty line rather than branched on, so EOF takes exactly the answer a bare Enter gives and the EOF branch cannot drift from the default the question advertises. On this base that is [Y/n] -> yes (the config is rewritten, over a backup), select [2] -> option 2, [Y/n] -> yes. Output is byte-identical: queuedLineAsker writes the prompt itself, exactly as rl.question did with terminal: false, and the tests assert the prompt string is still printed.

This applies the rule the file already documents under @ref LLP 0190#sync-gate ("EOF takes the stated default where the prompt has one"), and matches the sibling gate in hypaware-core/plugins-workspace/claude-desktop/src/consent.js, which was written against the line/close events specifically to dodge this class. No design change, no new LLP.

Regression test: before / after

test/core/walkthrough-prompt-eof.test.js. Each case is raced against a 500ms timer, because the pre-fix failure mode is a hang rather than a wrong value - an unraced assertion would simply never run.

Before (fix stashed, test kept):

not ok 1 - overwrite confirm takes its printed default on a stdin that ends without a line
not ok 2 - overwrite confirm takes its printed default on a stdin that was already spent
ok 3 - overwrite confirm still honours an explicit yes
not ok 4 - defaults gate takes its stated default on a stdin that ends without a line
ok 5 - defaults gate still honours an explicit pick
not ok 6 - backfill consent takes its printed default on a stdin that ends without a line
ok 7 - backfill consent still honours an explicit no
# tests 7 / # pass 3 / # fail 4

After: # tests 7 / # pass 7 / # fail 0.

Case 2 is the one a close-only guard would still hang on, so it is pinned separately. Cases 3, 5 and 7 pin that an answered prompt is unchanged.

Escalated, not fixed: item 1, the claude-desktop attach probe

This belongs in the design pipeline, not in a fix PR. The issue names the remedy as "give the claude-desktop client a plist-reading attach probe", but two Accepted LLPs settled the opposite, and attach_probe is not a label - it is the shared input to three consumers (probeClientAttachFromDescriptor, detachClientFromDisk, and attach-eligibility in src/core/config/action_attach.js). llp/0135-install-experience-overhaul.design.md#no-probe gives three independent reasons, any one sufficient:

  1. Wrong file type. The plist is XML; every core probe path JSON.parses the settings file.
  2. Wrong path. settings_file is $HOME-relative by contract (resolveClientSettingsPath, @ref LLP 0045#settings_file-is-home-relative-and-a-violation-is-loud), so /Library/Managed Preferences/... re-anchors under $HOME.
  3. No undo record, and the file is root-owned, so an unprivileged detach could not reverse it anyway.

llp/0115-claude-desktop-managed-config-attach.decision.md#no-attach-on-join (Accepted) already settled that Desktop registers no probe, and hypaware-core/plugins-workspace/claude-desktop/src/index.js carries the @ref recording it. Declaring one here would also make claude-desktop attach-eligible in the LLP 0044 join loop, which LLP 0115 forbids.

Quieting client_attach_missing by observation therefore needs a new mechanism - a read-only state channel separate from attach_probe, or a relaxation of the $HOME-relative contract plus a plist probe format - not a fix. That is a new request LLP re-entering the design pipeline. Nothing in this PR presupposes either shape.

Left: item 2 and item 3's second bullet

  • Item 2, a signedIn() predicate on AnthropicCredentialCapability. The issue itself calls it "purely optional polish" that "widens a plugin contract" and a "maintainer's call". Not a bug, no failing behaviour to pin, and a capability-contract widening is a design decision.
  • Item 3, runWizardSyncNow's catch. Reachable only via a throwing confirm factory, and the review that found it explicitly judged a guard not worthwhile. The issue records it so the judgment is findable; re-litigating it here would be scope creep with no reproducing test behind it.

Testing

  • Full suite on a fresh install at the current head: npm test -> 4091 tests, 4090 pass, 0 fail, 1 skipped. npm run typecheck clean. (The originally reported "15 failures before and after, identical set" came from a stale node_modules and does not reproduce on a fresh install; measured independently in review round 1 and again in triage.)
  • npm run smoke -- walkthrough_picker_to_first_query: fails identically on the original base with and without the change; output diff was empty modulo the run-id and tmpdir.

Note on PR #771

#771 flipped defaultOverwriteConfirmFactory's default to yes and has since merged; this branch merged that master in (c741a98) and the expected textual conflict was resolved there. The compatibility claim held: this PR does not hardcode an EOF answer, it routes null through the existing parse, so the [Y/n] default that function now advertises is the one EOF takes.

Fixes #772

test and others added 6 commits August 14, 2026 17:32
…772)

`rl.question()` leaves its promise permanently unsettled when the input
stream ends without a line, so the three legacy readline prompts in
`src/core/cli/walkthrough.js` hung forever on a spent stdin: the overwrite
confirm (`hyp init < /dev/null` never returns), the defaults gate, and the
backfill consent. The same file already solves this for the numbered picker
with `queuedLineAsker`, which resolves a pending ask as `null` on `close`
and seeds `closed` from `readableEnded` so an interface built over an
already-ended stream does not wait on an `end` it will never see.

All three prompts now read through that helper and coalesce `null` to the
empty line, so EOF takes exactly the default the question printed
(`[y/N]` -> no, `select [2]` -> option 2, `[Y/n]` -> yes) and the branch
cannot drift from the advertised default. Output is byte-identical:
`queuedLineAsker` writes the prompt itself, the way `rl.question` did.

test/core/walkthrough-prompt-eof.test.js races each prompt against a timer,
because the pre-fix failure is a hang rather than a wrong value. 4 of its 7
cases fail on master and all 7 pass here.

Co-Authored-By: Claude <noreply@anthropic.com>
Conflict: src/core/cli/walkthrough.js, defaultOverwriteConfirmFactory's
return expression. #771 flipped the confirm's default to yes ("only an
explicit no declines"); this branch routes the prompt through
queuedLineAsker so EOF settles as `null` instead of hanging. Both
intents compose: the null is coalesced to the empty line and read by
#771's parse, so a spent stdin takes the yes the printed [Y/n] promises
and the config is regenerated over the backup the caller already takes.

The EOF regression test's two overwrite cases follow the default they
assert: EOF now returns true and the prompt reads [Y/n]. Its third case
now scripts an explicit `n` rather than a `y`, since after the flip only
the decline distinguishes an answered prompt from a defaulted one.
This branch merged master mid-flight and inherited two documents both
claiming LLP 0223: the converge-on-applied-config decision (PR #770,
landed first) and the prune-direct-children-and-unreadable-assets
decision (PR #749, landed second). CI's duplicate-numbers check and
test/core/llp-ref-hygiene.test.js both fail on the collision.

Per LLP 0156#renumber, the later claimant moves. 0226 is already
spoken for by fix/issue-774, a sibling branch fixing the same
collision on master directly, so this renumbers to 0227, the next
free number above the highest claimed across origin/master and every
remote branch. Mechanical rename only: no content, status, date, or
reasoning changed.

The inbound sweep retargets the Extended-by header and four body
links in LLP 0219, two @ref [implements] annotations in
src/core/runtime/client_assets.js, one in
src/core/runtime/client_asset_ledger.js, and five @ref [tests]
annotations in test/core/client-assets-prune.test.js. References to
LLP 0223 that mean the converge decision (src/core/config/apply.js,
src/core/cli/wizard/join.js, src/core/cli/remote_commands.js,
test/core/remote-login-command.test.js, llp/0129, llp/0135) are
untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
`hyp init < /dev/null` was cited in both the overwrite confirm's JSDoc
and the test header as the run this change unhangs. It is not: the
wizard's first screen is `runWizardFork`, whose `legacyMenuPrompt`
(src/core/cli/wizard/fork.js) still reads through `rl.question` and so
still hangs a fully unanswered `hyp init` one screen before any of the
three prompts fixed here.

What these three do fix is real and reachable: `hyp clients enable`
reaches the backfill consent directly through
`maybeBackfillAfterEnable` with no TTY gate, and a partially scripted
wizard run (fork answered, stdin then dry) reaches the express gate,
the defaults gate and the commit-point confirm. The examples now name
those instead, and the test header records the fork prompt as the
remaining member of the class.

Comment-only: no behaviour, no output bytes, all 7 cases still pass.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Verdict: approve with findings. Reviewed manually against the round brief (codex / code-review are not invocable in this environment, so no automated reviewer ran). The change itself is correct, minimal, well-pinned, and convention-clean; the findings are about the claim, not the code. Reviewed at c741a98, pushed one comment-only fix, re-verified at 04fb00c (the SHA in the marker).

Verified

  • EOF takes the printed default on all three prompts. Checked each printed string against the parse it now flows through:
    • src/core/cli/walkthrough.js:334 prints Continue? [Y/n], EOF -> '' -> !/^n(o)?$/i.test('') -> true (proceed). Matches.
    • src/core/cli/walkthrough.js:456 prints select [${fallbackIdx + 1}], EOF -> '' -> parseInt NaN -> returns fallback, the same option the printed index names. Matches.
    • src/core/cli/walkthrough.js:527 prints [Y/n], EOF -> '' -> true (yes). Matches.
      No EOF branch exists to drift, because null is coalesced into the one parse rather than branched on. That is the right shape.
  • The regression test is real, not decorative. Swapped in origin/master:src/core/cli/walkthrough.js under the new test: # pass 3 / # fail 4, exactly the four EOF cases and exactly the set the PR body claims. Restored, # pass 7 / # fail 0.
  • Full suite green on this base: npm test -> 4091 tests, 4090 pass, 0 fail, 1 skipped. npm run typecheck -> clean. (The PR body's "15 failures before and after" does not reproduce on a fresh npm install; nothing to worry about, but the body understates the state.)
  • Conventions: no semicolons, no U+2014 anywhere in either file, JSDoc types only, @ref anchors resolve (llp/0190#sync-gate exists; test/core/llp-ref-hygiene.test.js passes).
  • Reachability, partly. interactive is !opts.picks at src/core/cli/wizard/index.js:213 and src/core/cli/walkthrough.js:644, never "stdin is a TTY", and defaultOverwriteConfirmFactory is not TTY-routed at all. The other two route through shouldUseTui, which is false in a non-TTY, so the legacy path is the non-TTY path. Confirmed. But see finding 1 for which non-TTY runs actually arrive.

Findings

1. hyp init < /dev/null, the PR's headline reproducer, still hangs - one screen earlier (blocker for the claim, not for the merge)

src/core/cli/wizard/fork.js:386 (legacyMenuPrompt, behind both legacyForkPrompt and legacyReturningGatePrompt) still calls rl.question directly. It is the wizard's first screen (src/core/cli/wizard/index.js:218, inside the same if (interactive)), so it is reached before the express gate, the pick lane, the commit-point confirm and the backfill consent - all three prompts this PR fixes.

Probed directly on this branch:

legacyForkPrompt({ stdin: endedPassThrough, ... }) at EOF -> HUNG (500ms)

So on hyp init < /dev/null the user-visible symptom is unchanged: still no error, still no exit code, still nothing to grep for. The PR body states the opposite in three places ("hyp init < /dev/null never returns" listed under Fixed, and "so hyp init < /dev/null ... land here"), and the same example had been baked into the shipped source.

This is not a reason to hold the PR. What it fixes is genuinely reachable and genuinely valuable:

  • src/core/commands/clients.js:803 (maybeBackfillAfterEnable) builds the backfill consent with no TTY gate at all, so hyp clients enable <client> < /dev/null hit the hang directly and no longer does;
  • a partially scripted wizard run (printf '2\n' | hyp init - fork answered, stdin then dry) walks the express gate, the defaults gate and the commit-point confirm, all of which now land on their printed defaults instead of hanging.

Fixed here (comment only, 04fb00c): the two in-source citations of hyp init < /dev/null now name runs that do reach these prompts, and the test header records wizard/fork.js as the remaining member of the class. Behaviour and output bytes untouched; 7/7 still pass.

Left for a human: whether legacyMenuPrompt should join the class. It is out of scope for this PR by more than one file - queuedLineAsker is module-private to walkthrough.js, so closing it means deciding where the shared helper lives, and deciding what EOF means at that prompt. The fork's stated default is quit, so applying this PR's rule mechanically makes a dropped terminal exit 0 having silently done nothing, which is a decision (versus the pick multiselect's deliberate PromptCancelledError/exit 130 under LLP 0190). That needs an owner, not a reviewer's guess. Two further siblings for whoever picks it up: src/core/cli/confirm.js:30 (lower exposure - requireConfirmation gates on isTty, so only a TTY ctrl+D reaches it) and src/core/plugin_install/confirm.js:150.

2. Backfill consent EOF = yes: defensible, not a finding (opinion, as asked)

I looked for the divergence the PR body invites pushback on and do not think it is one. llp/0139-desktop-picker-consent.decision.md#default-no itself names the backfill consent as the deliberate contrast and gives the reason: "unlike the backfill consent prompt, which defaults to yes. Backfill reads local files this machine already has; this acquires a credential, escalates to root, and writes a file outside the user's home." The desktop gate's EOF = no is bought by root escalation, credential acquisition and a browser OAuth launch. Backfill has none of those: local files already on the machine, into the local cache.

Two further reasons the divergence holds rather than leaks:

  • The desktop gate has since been superseded on the default itself: llp/0224#one-question-default-yes flipped it to [Y/n] and kept only the non-answer rule ("every non-answer declines, with the hint, without hanging"). So the sibling is not "EOF = no because consent"; it is "EOF = no for this specific escalation", now explicitly separated from the printed default.
  • LLP 0190's stated rule is EOF takes the question's stated default "never a different one", and its cancel carve-out is narrow and reasoned (an empty pick set is indistinguishable from a real answer). A yes/no with a printed default is not that case.

One non-blocking observation, offered as an option and not a request: the desktop path says when it declines on a non-answer, and this one says nothing - a transcript of hyp clients enable < /dev/null shows the question and then the import, with no line recording that nobody answered. A "no answer read, taking the default" line would make the unattended path auditable, but it would also break the byte-identical output property this PR deliberately preserves and the goldens that rest on it. I would leave it.

3. The 500ms race is not a flake risk (preference-grade, no action)

Measured per-case settle time on this branch is 0.15-0.35ms, so the margin is roughly 1500x. The timer is process-local, resolves rather than rejects, and is cleared in a finally, so a slow runner costs nothing and only a >500ms event-loop stall inside the worker could trip it. That is a far weaker assumption than most timing tests carry. Adequate as written; I would not raise it.

4. LLP 0190's prose is now stale (preference-grade)

llp/0190-wizard-defaults-gate.decision.md still ends #sync-gate with "The file's other readline prompts (the overwrite confirm, the gate's numbered fallback, the backfill consent) still call rl.question directly and still hang at EOF; closing that class is a separate change." This PR is that separate change, and the doc is Status: Draft, which CLAUDE.md leaves editable - so unlike an Accepted doc this could take a one-sentence mechanical correction in the same commit under the living-docs rule. Raising it as preference only, per the round brief; I did not author or edit any doc.

Notes on things that are not findings

  • Routing every ask through queuedLineAsker also makes these three prompts queue lines from interface construction rather than dropping them. It is inert here (each interface asks exactly once, and the ask is issued in the same tick as construction), and it is strictly more forgiving than rl.question, which dropped any line arriving before the question.
  • select [0] when question.default names a value absent from options (findIndex -> -1 at src/core/cli/walkthrough.js:450) is pre-existing and untouched; EOF still returns the fallback the prompt meant. Not this PR's.
  • The reverted LLP-renumber churn (5ce4283 / aaef3fd) is correctly absent from the diff; git diff origin/master...HEAD is exactly the two files.

Disposition

Blockers: none. Finding 1 is a blocker for the PR description, which I cannot edit and did not; the source citations it had leaked into are fixed. Findings 2-4 are preference-grade. The fork prompt is a genuine follow-up needing a design call on EOF semantics.

…unreachable

Round 1 replaced `hyp init < /dev/null` with `hyp clients enable
< /dev/null` in the test header. That run is wrong twice over: there is
no `clients` command (`hyp attach <client>` is the one that enables),
and the backfill consent is not reachable on a piped stdin at all -
`maybeBackfillAfterEnable` runs only when `activatedViaPrompt` is set,
and both sites that set it go through `maybeInteractiveEnableAttach`,
which returns early on `!isTty(ctx.stdin)`.

Verified: `hyp attach claude < /dev/null` exits 1 on the not_enabled
refusal without asking anything. The header now names the run actually
probed on this branch - answer the fork, let stdin dry, and the express
gate, the commit-point confirm and the backfill consent all settle on
their printed defaults over the spent stream - and states the narrower
terminal-drop shape that reaches the attach caller.

LLP 0190 #sync-gate said these three prompts "still call `rl.question`
directly and still hang at EOF", which the code this PR annotates with
that very anchor contradicts. 0190 is Draft, so the sentence is
corrected in place and now records the fork screen as the one prompt
left outside the file.

Comment and doc only: no behaviour, no output bytes. npm test 4091/0
fail, npm run typecheck clean.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Verdict: approve with findings. Round 2 of 2 (last review round). Reviewed manually in a fresh worktree with a real npm install (codex and code-review are not invocable in this environment, so no automated reviewer ran). Reviewed round 1's 04fb00c, pushed one comment-and-doc-only commit, and re-verified at df64da4 (the SHA in the marker). The code change is unchanged from round 1 and is still correct; both findings below are about claims, and both are fixed.

Round 1's comment-only fix: landed, half accurate

04fb00c landed as described (two lines of JSDoc plus the test header, no behaviour). Per the round brief I checked its new claims empirically rather than reading them, and one of the two is wrong in exactly the way round 1 was correcting.

1. hyp clients enable < /dev/null does not reach the backfill consent either (was blocker-for-the-claim; fixed in df64da4)

Round 1's replacement citation in test/core/walkthrough-prompt-eof.test.js:7 was hyp clients enable < /dev/null reaches the backfill consent directly, on the reasoning that maybeBackfillAfterEnable (src/core/commands/clients.js:796) carries no TTY gate. The function does not, but its only caller does, and the command does not exist:

  • There is no clients command. Run on this branch:
    node bin/hypaware.js clients enable claude < /dev/null -> hyp: unknown command 'clients enable claude', exit 2. The command that enables is hyp attach <client> (src/core/cli/core_commands.js:295).
  • hyp attach <client> < /dev/null never asks. maybeBackfillAfterEnable runs only under if (activatedViaPrompt) (src/core/commands/clients.js:486), and the only two things that set activatedViaPrompt (clients.js:201 and clients.js:273) both come from maybeInteractiveEnableAttach, which returns { activated: false } at clients.js:657 on !isTty(ctx.stdin). Probed: node bin/hypaware.js attach claude < /dev/null -> the not_enabled refusal, exit 1, no prompt.
  • On top of that, even with a TTY stdin the legacy consent is only reached when shouldUseTui is false (src/core/cli/tui-router.js:23 needs stdin and stdout to be TTYs), so the real shape there is a terminal with the TUI off or with stdout piped, which then drops.

Fixed here. The header now names the run I actually probed, and states the narrower terminal-drop shape for the attach caller.

2. Round 1's other replacement claim is accurate (verified)

"A partially scripted wizard run: fork answered, stdin then dry." Probed in-process on this branch, one PassThrough carrying 2\n then end(), handed to the real functions in sequence:

legacyForkPrompt with "2"                     -> "local"   (input.readableEnded = true)
express gate on the same spent stream         -> "defaults"
overwrite confirm on the same spent stream    -> true
backfill consent on the same spent stream     -> true

All three settle within 600ms on their printed defaults, over a stream a previous readline interface already drained. That is the run the fix is for, and it works.

3. The fork prompt still hangs (unchanged from round 1, re-verified)

legacyForkPrompt at EOF -> HUNG (600ms)

src/core/cli/wizard/fork.js:386. See the recommendation below.

Re-examination of the three fixed prompts

Nothing further to report; round 1's reading holds and I re-derived it from the current source.

  • EOF equals a bare Enter on all three, by construction rather than by coincidence. Each site coalesces before the single parse ((answer ?? '').trim() at walkthrough.js:340, (await askLine(...)) ?? '' at walkthrough.js:453, (answer ?? '').trim().toLowerCase() at walkthrough.js:526). There is no EOF branch anywhere, so there is no path on which the coalesced null can diverge from ''. I traced the one place a null could have escaped the coalesce, the allowBack b test at walkthrough.js:459, and it runs after the ?? '', so EOF cannot be read as a back-request.
  • Every caller's default is the safe direction. express.js:82 defaults, folder_ask.js:100 the standing answer, sync_now.js:173 WAIT (nothing leaves the machine), sync_scope.js:183 accept, wizard/index.js:255 stay (stays connected, does not run hyp leave). None of them distinguishes EOF from Enter, so none can drift.
  • The pre-existing select [0] when question.default names a value absent from options (findIndex -> -1, walkthrough.js:451) is untouched and still returns the intended fallback. Not this PR's.
  • Full suite on this base: npm test -> 4091 tests, 4090 pass, 0 fail, 1 skipped, before and after my commit. npm run typecheck clean. test/core/llp-ref-hygiene.test.js 11/11. node --test test/core/walkthrough-prompt-eof.test.js 7/7.

4. LLP 0190's stale prose: fixed here, not deferred

llp/0190-wizard-defaults-gate.decision.md#sync-gate said the overwrite confirm, the gate's numbered fallback and the backfill consent "still call rl.question directly and still hang at EOF". This PR annotates those three prompts with @ref LLP 0190#sync-gate [implements], so the shipped ref pointed at a section asserting the opposite of the code carrying it. CLAUDE.md's "keep refs honest" plus "land the doc edit in the same commit as the code" make that a defect in this PR, not a follow-up, and 0190 is Status: Draft so the sentence is editable rather than immutable. Corrected in place, and it now records the fork screen as the one prompt left outside the file. One sentence; nothing the doc decided was touched.

5. Behaviour worth stating plainly for the record (preference, by design)

A partially scripted run that used to hang before writing anything now completes a whole install unattended: express gate -> record all, commit-point confirm -> yes (config rewritten over a backup), daemon install, backfill consent -> yes (local history imported). Each step takes exactly the default its own prompt prints, which is LLP 0190's stated rule and identical to a user pressing Enter four times, so I do not think it is wrong. But it is a real change in what a dropped stdin causes, and it is not stated anywhere in the PR body, so triage should see it rather than discover it. No action requested.

6. A fourth member of the class round 1 did not list (preference)

hypaware-core/plugins-workspace/claude-account/src/index.js:167 also calls rl.question ('Code: ' in the OAuth paste fallback) and hangs at EOF when the loopback callback never fires. Lower exposure than the others (login is inherently interactive), but it belongs on the follow-up issue's list alongside src/core/cli/confirm.js:30 and src/core/plugin_install/confirm.js:150.

Recommendation on wizard/fork.js: deferral with a tracking issue, not a production blocker

Asked for a call without hedging, so: defer, and open the issue. The reasoning, in the order that decides it.

  1. This PR does not cause, worsen, or entrench the fork hang. legacyMenuPrompt hangs on master today, identically, and the diff is two files that do not include it. Holding the PR converts "one of four hangs left" into "four hangs left" and ships nothing. There is no version of blocking this that produces a better artifact than merging it.
  2. The PR makes the fork fix easier, not harder. queuedLineAsker (walkthrough.js:123) is precisely the helper the fork fix will reuse, including the readableEnded seeding that the fork's own case needs, since fork's prompt runs on a stream nothing has drained yet but a later screen's does not.
  3. The semantics question round 1 flagged is smaller than it looked, which argues for a clean follow-up rather than a rushed inclusion. Round 1 read EOF-at-fork as an open "quit versus cancel" design call. The code has largely answered it: legacyMenuPrompt's own JSDoc says it "resolves to quit on an empty, unparseable, or out-of-range answer so a non-TTY caller never reconfigures by accident", and runWizardFork carries @ref LLP 0129#fork: "quit is the safe default on a bare enter." A bare Enter there already returns quit (fork.js:393), so EOF-takes-the-printed-default gives quit too. That is this PR's exact rule, and it is safe: hyp init < /dev/null would exit 0 having changed nothing, not run an unattended install. It is also not the pick multiselect's carve-out, whose cancel exists because an empty selection is indistinguishable from a real answer; here the empty answer has a stated meaning.
  4. What genuinely still needs an owner is one line, and it is a judgement call a reviewer should not make alone: is exit 0 the right code when the terminal dropped rather than the user choosing Quit? An argument exists for 130 (a dropped terminal did not choose anything, and CI would rather see nonzero). Plus the placement question: queuedLineAsker is module-private to walkthrough.js, and doing this properly means extracting it to a shared module that four call sites will want.

So: merge this, and file "the wizard's fork screen hangs at EOF" as its own issue carrying (a) the exit-code question, (b) the helper-extraction placement, and (c) the sibling list in finding 6. If the maintainer disagrees and wants hyp init < /dev/null closed in one PR, that is a scope preference, not a correctness one, and the cost is a shared-module extraction plus one decision.

Disposition for triage

# Finding file:line Severity State
1 Round 1's replacement citation names a nonexistent command and an unreachable run test/core/walkthrough-prompt-eof.test.js:7 blocker for the claim fixed in df64da4
4 LLP 0190 asserts the opposite of the code that @refs it llp/0190-wizard-defaults-gate.decision.md:118 blocker for ref honesty fixed in df64da4
A PR body still claims hyp init < /dev/null is fixed, in two places, one of them under the "Fixed" heading. It is not; the run still hangs at src/core/cli/wizard/fork.js:386. PR description not a production blocker (no code defect) but must be corrected by a human before merge if this body becomes the squash-merge message, or master permanently records a false fix claim. Reviewers may not gh pr edit, so I could not. unfixed, needs a human
B legacyMenuPrompt hangs at EOF src/core/cli/wizard/fork.js:386 not a blocker for this PR; a real production bug on master, pre-existing and untouched here. Deferral with a tracking issue, argued above. unfixed, deliberate
5 A dropped stdin mid-wizard now completes an install instead of hanging src/core/cli/walkthrough.js preference (by design, matches LLP 0190) no action
6 Fourth rl.question site not on round 1's sibling list hypaware-core/plugins-workspace/claude-account/src/index.js:167 preference add to the follow-up issue

Round 1's findings 2 (backfill consent EOF equals yes: defensible) and 3 (the 500ms race is not a flake risk) were resolved there and are not re-litigated.

Production blockers in this PR: none. One item (A) needs a human before merge; one (B) needs an issue after it.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Triage verdict: ship. No production blockers. Both review rounds are spent; this is the triage decision at head df64da4, with every load-bearing claim re-verified empirically in a fresh worktree rather than adopted from the rounds.

What triage re-verified independently

  • node --test test/core/walkthrough-prompt-eof.test.js: 7/7 pass. Full suite on a fresh install: 4091 tests, 4090 pass, 0 fail, 1 skipped. npm run typecheck clean.
  • In-process probe at this head: legacyForkPrompt at EOF never settles (hung at 600ms); fork answered with 2 then the overwrite confirm on the same spent stream settles to its printed default. The fix works for the run it claims; the fork screen defect is real and untouched.
  • Process-level probe: hyp init < /dev/null with stdout piped is refused at the non-TTY gate (src/core/commands/init.js:129, exit 2). With a pty stdout it prints the fork menu, then dies with exit 13 on node's unsettled-top-level-await drain (nothing else keeps the loop alive there; with any live handle it hangs instead). So the original PR body's headline reproducer was wrong in both directions: not fixed by this PR, and not reaching the prompts this PR fixes.
  • hyp attach claude < /dev/null does not prompt and does not hang, so round 2's claim that no piped run reaches the attach-side consent holds. One environment note for the maintainer: on this triage host that probe attached for real, writing ANTHROPIC_BASE_URL and 7 assets into ~/.claude; triage immediately ran hyp detach claude, which removed all of it. No residue expected, stated for the record.
  • PR Onboarding language and flow fixes from first user-onboarding feedback #771 is merged, so the original body's "Onboarding language and flow fixes from first user-onboarding feedback #771 (open)" note and its [y/N] default-no table were stale against the current base, where the overwrite confirm prints [Y/n] and EOF proceeds with the rewrite over a backup.

Independent judgement on wizard/fork.js:386

Deferral with a tracking issue, not a blocker. Triage agrees with round 2's recommendation after re-deriving it:

  1. The defect exists identically on master; this diff is two files plus a test and does not touch it. Blocking converts "three hang sites fixed, four left" into "zero fixed, seven left" and ships nothing.
  2. Exposure is narrower than "any piped run": the wizard is only reached when stdout is a TTY (src/core/commands/init.js:129 refuses otherwise), so the real shape is a terminal run whose stdin dries up or drops, and the observed failure there today is a dirty exit 13 or a hang, not a silent unattended install.
  3. The EOF semantics at that screen are already settled in code and docs (legacyMenuPrompt's JSDoc, @ref LLP 0129#fork: an empty answer means quit), so a mechanical application of this PR's rule gives quit with nothing changed, the safe direction. The genuinely open questions (exit 0 versus 130 when the terminal dropped; where the shared queuedLineAsker helper lives) are owner decisions, and they now have a tracker.

Findings disposition

Finding Class Resolution
PR body claimed hyp init < /dev/null fixed; would have become a false squash-merge record factual defect in the record, correctable Corrected by triage via gh pr edit: the body now opens with an explicit correction block stating what was believed, what is actually fixed, and what remains. Nothing true was removed.
Stale [y/N] table, "15 failures" testing note, and "#771 (open)" after the base moved same Corrected in the same edit (table lines now 316/439/507 on current master, suite state re-measured fresh: 4090/4091 pass).
fork.js:386 EOF unsettled promise pre-existing production bug on master, outside this diff Deferred to #783 with the two owner decisions and the sibling list.
Remaining rl.question siblings: hypaware-core/plugins-workspace/claude-account/src/index.js:167, src/core/cli/confirm.js:30, src/core/plugin_install/confirm.js:150 preference / follow-up Named in #783.
A dropped stdin mid-wizard now completes an install unattended preference, by design (LLP 0190's rule, identical to four bare Enters) Stated plainly in the corrected PR body so it is discoverable, not discovered.
Backfill consent EOF equals yes; 500ms race margin resolved in round 1 as defensible / non-issues No action.

Follow-up issue: #783 (label neutral:fix), "Follow-up: close the remaining rl.question EOF-hang class".

Not done by triage, per policy: no gh pr ready, no merge. The PR is ready for the maintainer's merge decision as it stands.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

No description provided.

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 15, 2026
@philcunliffe
philcunliffe merged commit 9d62990 into master Aug 17, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-772 branch August 17, 2026 21:44
philcunliffe added a commit that referenced this pull request Aug 18, 2026
…confirms, and the OAuth paste (#785)

* Close the rl.question EOF-hang class: fork menu, both y/N confirms, the OAuth paste

`node:readline`'s `rl.question()` leaves its promise permanently unsettled
when the input stream ends without a line. PR #773 fixed the three prompts
in `walkthrough.js`; the four sites outside it had the same defect. The
worst is the wizard's fork menu, which is the first screen `hyp init` shows,
so a terminal whose stdin dried up never got past it: exit 13 on an
unsettled top-level await when nothing else held the event loop, an
indefinite hang when something did.

Two decisions this makes, both stated in LLP 0190 #eof-everywhere:

- The asker lives in `src/core/cli/line_asker.js`, beside `stdio.js` and
  `flush-streams.js` in the tree's existing home for small shared CLI
  helpers, not in `src/core/util/` (fs and JSON) and not in a new top-level
  location. Plugin workspaces already import `src/core/...` by relative
  path, so `claude-account` reaches it the way it reaches the observability
  and usage-policy modules. `queuedLineAsker` moves there unchanged;
  `askLineOnce` joins it for the prompts that ask once on an interface that
  may be a real terminal, keeping `rl.question` as the thing that writes the
  query (readline redraws a terminal line from its own cursor bookkeeping)
  and replacing only its promise.

- EOF at the fork exits 0, not 130. The prompt prints `default 3` and
  LLP 0129 #fork settled that the default is Quit, so a spent stdin takes
  the answer the screen advertised; the fork's TUI path already returns
  `quit` for a real ctrl+c, so 130 in the readline fallback would judge a
  dropped terminal more harshly than the TUI judges a deliberate cancel.
  130 stays where LLP 0135's cancel put it: prompts whose enter answers
  nothing.

The `Code: ` paste in `claude-account login` is the one prompt with no
default, so it does not invent one. With no loopback listener left to finish
the sign-in, EOF is a failure that says so; with a listener up the paste lane
stays pending rather than losing a race the browser may still win.

Regression tests race every case against a 500ms timer, because the pre-fix
failure mode is a hang and an unraced assertion never runs. Nine of the
fourteen fail on master's behaviour and all fourteen pass here.

* askLineOnce must not let EOF outrun an answer that arrived (review of #785)

`rl.question` hands its answer back through a promise, a microtask
late, while `close` fires synchronously. A stdin that delivers the
line and the EOF in one burst - `Readable.from(['y\n'])`, the idiom
this repo's own stdin fixtures use, or any readable that pushes data
and `null` together - had `close` win that race, so a typed `y` at
an irreversible `[y/N]` was silently read as the printed no, and a
pasted OAuth code was silently discarded. `rl.question` alone got
these right, so the new asker was strictly worse than what it
replaced.

The EOF settlements now run a turn behind the answer, and the last
line of a stream that ends without a trailing newline (readline hands
that one to `line`, never to the pending question) is taken too.
Genuine EOF, spent streams, and real-terminal redraw are unchanged:
verified on a pty for both the paced-keystroke redraw and ctrl+D, and
for `hyp init < /dev/null` still exiting 0.

Also corrects the stale claim beside the OAuth race that closing the
interface rejects a pending question. It does not; that is the defect
being worked around.

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

* askLineOnce must answer with the line that answered it (review 2 of #785)

The `rl.on('line', done)` added in 2843b78 takes the LAST line of a
burst rather than the first. Readline emits `line` only while no
question is pending, so the handler fires for everything typed or
pasted past the answer, and it fires synchronously while the question
hands its own answer back a microtask later. A stdin delivering
"n\ny\n" in one chunk therefore answered `y`.

That is the one direction an irreversible `[y/N]` must never drift in:
`hyp purge`, `hyp report delete` and `hyp attach`'s enable prompt read
a typed `n` as a confirmation. Reproduced on a real pty, where a paced
`n` is still correct but a pasted "n\ny\n" proceeded with the delete;
both `rl.question` and the pre-2843b78 asker returned "n" there, so it
was a regression introduced by the fix rather than a pre-existing gap.

The line is now held rather than settled on, and read only at EOF and
only if the question never answered. That keeps 2843b78's two cases
(an answer delivered in the same burst as the EOF, and the
unterminated last line readline routes past the question) and drops
the overwrite. On every input where `rl.question` answers at all,
`askLineOnce` now returns exactly what it returns.

Three regression tests, each verified to fail against 2843b78's body
and pass against this one.

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

* Drop npm-install.log, committed by accident

It was picked up by the round 2 fix commit. It is not gitignored and is
not in the published files set, so it has no runtime effect, but it would
otherwise land on master as build noise.

---------

Co-authored-by: test <test@example.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: neutral <neutral@example.com>
Co-authored-by: test <test@test.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Follow-up: deferred review findings from PR #771

1 participant