Numbered fallback re-asks once on an answer naming no row (#634) - #648
Conversation
"y", "0", or an out-of-range index names no row, so it fell through to [] - in the wizard's sync menu that silently opted every candidate out. The fallback now says what did not match and asks once more, and "none" becomes the word for a deliberate empty selection. The first attempt at this (991388d, reverted) wrapped the shared factory's Q&A in an ungated `while (true)`. This one is bounded on every axis it failed: - Gated on `enterKeepsChecked`, so only the sync menu (the one with the inverse default) re-asks. The pick menus and runPickerWalkthrough ask once, with byte-identical prompts and unchanged answers. - Capped at one re-ask, then the historical [] stands, so no input can hold a scripted run at the prompt. - Readline's close resolves the pending ask with the same default a bare enter takes, instead of leaving `rl.question` unsettled (a hang, or a silent exit 13). - Answer lines are queued from the moment the interface opens, so a correction arriving in the same chunk as the typo is not dropped between two `question()` calls. LLP 0190 #sync-gate amended (Draft). Co-Authored-By: Claude <noreply@anthropic.com>
Review of #648 found the bounded re-ask re-creating the failures it exists to prevent, one answer later. A spent budget returned []. In the sync menu [] means "opt every candidate out": issue #634 again, inside the loop that closes it. And the "nothing matched" line was suppressed on the FINAL failure, so the user got one message and then silence. The fallback now lands on the same default a bare enter takes (the checked rows where enter keeps them, the historical empty selection everywhere else) and names what it kept. The non-flagged path is unchanged: its enter is [] too. EOF resolved every question with a default, which turned a dropped terminal into "the user chose nothing" and carried the wizard on into the daemon install with zero sources. Under a pty with stdin closed at the source picker, master aborts (rc 13, unsettled await) and this branch printed "Step 2 of 2 - Finish setup" and installed. A question with no stated default now cancels instead: PromptCancelledError, the signal the TUI already raises on ctrl+c, which every one of the three call sites already catches. Same pty run now ends "hyp init: cancelled" at rc 130, with the unsettled-await hang still gone. Two smaller ones: - `closed` was seeded false, but readline registers its `end` listener at construction, so an interface built over an ALREADY-ended stream never emits `close` and the next ask waited forever. It is seeded from the stream's own `readableEnded` now. The file's three other readline prompts still call `rl.question` and still hang at EOF; routing them is a separate change and LLP 0190 now says so rather than implying the class is closed. - "none" was reachable only via the gated re-ask message. The opted-in prompt line advertises it. Every other prompt line stays byte-identical to origin/master, verified across both back variants and six answers. LLP 0190 #sync-gate amended (Draft). Co-Authored-By: Claude <noreply@anthropic.com>
Neutral review round 1 -
|
| build | rc | outcome |
|---|---|---|
master 7116f95 |
13 | unsettled await at the source picker, nothing further |
a251538 |
1 | resolves [], prints "Step 2 of 2 - Finish setup", attempts the daemon install with zero sources |
1ee4e19 |
130 | hyp init: cancelled, no step 2, no install |
A dropped terminal was being read as "the user chose nothing" and the run then committed. Fixed by raising PromptCancelledError on a null line for questions without enterKeepsChecked, reusing the error the TUI already raises on ctrl-C; all three call sites already route it to WALKTHROUGH_CANCEL_EXIT_CODE. The flagged sync-menu path still keeps its defaults on EOF.
3. LOW - the EOF fix did not compose. closed is per-interface, and readline registers end at construction, so over an already-ended stream close never fires and askLine awaits forever (four sequential asks: Q1 and Q2 resolve, Q3 hangs). Not a regression, master hangs a prompt earlier, but the LLP text claimed more than it delivered. Fixed by seeding closed from input.readableEnded. The three sibling factories in this file still use bare rl.question and still hang at EOF; rather than change three behaviours in this PR, LLP 0190 now says so explicitly and names closing that class as a separate change. I agree with that call.
4. LOW - "none" was undiscoverable, advertised only in the gated re-ask message. Now in the prompt line for the flagged path, with the JSDoc scoped to match.
Verification of the fix
Five new regression tests, all confirmed failing on a251538, including a full runPickerWalkthrough case asserting rc 130 and no config written, and one that reproduces the sequential-ask hang. Hostile-input properties re-verified after the change with no rc=124. npm test 3587 pass / 0 fail / 1 skip, tsc clean, walkthrough_picker_to_first_query ok.
One honest note carried from the fix: walkthrough_to_first_query fails, but identically on origin/master and a251538, so it is pre-existing and not from this change.
Neutral review round 2 -
|
| result | |
|---|---|
| master | optedOut: ["claude","otel"] - #634 reproduced, every candidate silently opted out |
| branch | optedOut: [], with nothing matched 'y' - keeping the checked rows: claude, otel |
2. EOF side effects (MEDIUM) - FIXED. Under a pty: master gives "Detected unsettled top-level await" at rc 13; the branch gives hyp init: cancelled at rc 130, no "Step 2 of 2", no daemon install, no config written. The flagged sync path still keeps its defaults on EOF.
3. readableEnded seeding (LOW) - FIXED. Two asks over one stdin: master resolves the first then hangs at rc 13; the branch settles both. Reachable for real, since the gate's readline swallows the whole buffer and the menu is then built over an already-ended stdin.
4. "none" (LOW) - FIXED, advertised on both flagged prompt variants with JSDoc scoped to match.
The cancel change does not break anything that used to work
This was the risk worth chasing, since throwing where the code previously returned a value changes control flow on a shared factory. All three call sites catch PromptCancelledError and the orchestrator honours the result; no uncaught rejection anywhere. Crucially, on master the non-flagged prompt at EOF never returned [] - rl.question left the promise unsettled at rc 13 - so there is no state master succeeded in that now fails. Scripted --yes/preset runs supply opts.picks and never construct the factory, and hyp init gates non-TTY stdin at rc 2 before any prompt.
Hostile input
15 cases. The only rc=124 is a stream that never closes, byte-for-byte identical to master and correct behaviour for an open prompt. Every direct-hang case master had (rc 13) now settles in ~110 ms. Non-flagged path byte-identical: 16/16 combinations of {allowBack} x {8 answers} match master in stdout bytes, return value and thrown error; all differences are on the flagged path, as intended.
npm test 3587 pass / 0 fail / 1 skip, tsc clean, walkthrough_picker_to_first_query ok. LLP 0190 is Draft, and its amended text does not overclaim: it says plainly that the three sibling factories still use rl.question and still hang at EOF.
Worth knowing, not blocking
The residual hang class fronts the flow. printf '' | hyp init still exits 13 on this branch, identical to master, because it hangs at fork.js's "Join a team, or set up HypAware locally?" prompt before ever reaching the picker. The class is also wider than the LLP sentence's scope: fork.js:360, cli/confirm.js:30 and plugin_install/confirm.js:150 are all in it. Not a regression and not this PR's job, but the user-visible win only materialises once past the fork prompt, which is narrower than "a dropped terminal now cancels" reads. Worth a follow-up that closes the class.
Two cosmetic nits recorded and not fixed: the fallback message names descriptor ids (claude, openclaw) rather than the labels or row numbers the menu just rendered, and walkthrough-prompt.test.js:210 and :278 assert the same thing.
In the non-TTY numbered prompt an answer that names no row (
y,0, anout-of-range index) parses to
[], which the wizard's sync menu reads as"select nothing": a typo opts every candidate out of syncing. The opted-in
question now prints what did not match and asks once more, and
"none"is the word for a deliberate empty selection.
Why this one cannot loop or hang
The first attempt (
991388d, reverted) wrapped the shared factory's Q&A in anungated
while (true). Each of its four failures has a structural answer here:991388dfor (attempt = 1; attempt <= attempts; ...),attempts = 1 + 1. The loop cannot re-enter after the budget; the fall-through returns the historical[].question.enterKeepsChecked, which onlywizard/sync_scope.jssets. Everyone else computesattempts = 1: same bytes, same answers, same one ask.rl.questionunsettledcloseresolves the pending ask as "no answer", and the prompt takes the same default a bare enter takes.question()call, so both lines of a singley\n3\nwrite survive.rl.questioncannot do the last two jobs: it registers itslinelistener onlywhen called (readline emits a chunk's lines synchronously, a promise resolves a
microtask later) and at EOF its promise neither resolves nor rejects. So the
factory drives a small queued line reader over the same interface. With
terminal: falsereadline writes the query straight tooutput, so writing theprompt directly is byte-identical.
Empirical evidence
Real spawned process, real piped stdin,
timeout 20, noprocess.exit(a livehandle or an unsettled ask shows up as
rc=124).flagged= the sync menu'squestion,
plain= every other caller.991388d, the reverted implementation, on this harness:This PR:
Every
plainrow is byte-for-byte and answer-for-answer identical to master(200/200, 228/228, 221/221, 208/208 bytes). The only master row this PR changes
outside the gate is
immediate-EOF, which master exits 13 on("Detected unsettled top-level await") and this PR resolves.
Through the packaged CLI on the reachable path from the issue's note (pty stdout,
fifo stdin),
{ printf '2\n'; yes; } | hyp initfinishes in well under the20s fuse:
rc=0 bytes=1441 select_prompts=1(master: identical;991388d:rc=124, ~1MB).Regression tests
Nine cases in
test/core/walkthrough-prompt.test.js, including two new drivers:askPiped(whole script in one chunk, then EOF) andaskEndless(answers everyprompt forever, never closes, with a 10-ask fuse so a regression fails instead of
hanging the suite). Against master:
All pass after the fix.
node scripts/run-tests.js: 3582 pass, 0 fail, 1 skipped.npx tsc -p tsconfig.json --noEmit: clean.npm run smoke -- walkthrough_picker_to_first_query: ok.LLP 0190
#sync-gate(Draft) amended in the same commit: it recorded this as adeferred papercut, and now records the bounded, gated decision and the two
invariants that keep it terminating.
Fixes #634
🤖 Generated with Claude Code