Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions llp/0185-unpicked-client-stays-attached.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
**Author:** Brendan / Claude
**Date:** 2026-08-04
**Related:** LLP 0045 (the one disk-driven undo), LLP 0129 (Reconfigure re-enters the picker), LLP 0086 (the existing attach drift diagnostic), LLP 0180 (the finale's attach lane), LLP 0031 (central layer)
**Extended-by:** LLP 0188 (the finale's print stays where §warn-do-not-detach put it; the wizard, whose closing sequence buries it, repeats it short at the end of the run)

> Re-running `hyp init` and unchecking a client the previous run attached
> regenerates a config without that client's adapter, while the client's own
Expand Down Expand Up @@ -90,6 +91,12 @@ of the `config_unreadable` finding that is the real repair. The known gap: a
The finale still warns about it at the time, which is where the state is
created.

> **Extended-by [LLP 0188](./0188-stranded-attach-warning-repeats-at-the-end.decision.md):**
> the finale's print is where this decision put it, but it is not the last
> thing `hyp init` writes. The wizard repeats it, short, after its first look
> and before the privacy narration; `runPickerWalkthrough`, which writes
> nothing substantial after the finale, still prints it once.

<a id="not-configured-means-not-active"></a>**"Not configured" means what
`hyp status` already means by it.** Both surfaces read a **local layer** plugin
entry with `enabled: false` as inactive, so a switched-off adapter strands its
Expand Down
115 changes: 115 additions & 0 deletions llp/0188-stranded-attach-warning-repeats-at-the-end.decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# LLP 0188: The stranded-attach warning repeats where the run ends

**Type:** Decision
**Status:** Accepted
**Systems:** Onboarding, CLI, Clients
**Author:** Brendan / Claude
**Date:** 2026-08-05
**Related:** LLP 0185 (an unpicked client is named, not detached; this doc is its `Extended-by`), LLP 0135 (the wizard's closing sequence), LLP 0100/0101 (the privacy narration), LLP 0086 (the attach drift diagnostic)

> [LLP 0185](./0185-unpicked-client-stays-attached.decision.md) put the
> stranded-attach warning in the finale, before the daemon restart. It is
> still there. But `hyp init` writes about seventy more lines after the finale
> returns, so on a real terminal the warning is not on screen when the run
> ends, and the user who does not scroll back never reads it.

## Context {#context}

`runPickerFinale` prints the warning and returns. What happens next depends on
which entry point called it:

- `runPickerWalkthrough` writes its short run summary and stops. The warning
is within a handful of lines of the last thing on screen.
- `runInitWizard` writes the run summary, then `runWizardFirstLook` (roughly
sixty lines of real query output, by its own design in
[LLP 0135 §first-look](./0135-install-experience-overhaul.design.md)), then
on the team path the privacy narration. Nothing pauses between them.

So the same print is prominent on one path and buried on the other. It is
buried worst exactly where it is needed most: on a managed host,
[LLP 0185 §status-backstop](./0185-unpicked-client-stays-attached.decision.md#status-backstop)
gates the `client_attached_not_configured` diagnostic to hosts with no central
layer, so the finale's print is the *only* signal a wizard-attached client the
reconfigure run left unpicked ever gets. A joined host has no backstop to
scroll back to.

The obvious repair, echoing `finaleSummary.attachedNotConfigured` from
`writeWalkthroughRunSummary`, is wrong twice over. That function is shared by
both entry points, so `runPickerWalkthrough` would print the same warning
twice within a few lines of itself, and the wizard would still bury the echo
under the first look, which is the whole complaint.

## Decision {#decision}

<a id="repeat-at-the-end"></a>**The finale's print stays where it is, and the
caller that buries it repeats it at its own end.** LLP 0185's placement is
unchanged: the finale still names the stranded clients after the config write
and before the daemon restart. `runInitWizard` additionally writes a short
repeat after the first look and before the privacy narration, naming the same
clients and the same `hyp detach --client <name>` lines.

The repeat is the *caller's*, not the shared run summary's, because only the
caller knows whether anything came between. `runPickerWalkthrough` prints
nothing substantial after the finale, so it does not repeat, and no path
prints the warning twice on one screen.

<a id="when"></a>**The repeat is conditional on the closing sequence having
written something.** The wizard emits it only when the first look actually
reached the screen. That is a narrower test than "the first look ran", and
deliberately so: the step is documented to degrade to a silent skip rather
than fail a finished install
([LLP 0135 §first-look](./0135-install-experience-overhaul.design.md)), so an
unregistered dataset, an unreadable cache, or a render that throws leaves an
attended run that attempted the block and printed none of it. Nothing buried
the finale's print on such a run, so nothing is repeated.

That single condition covers the team pathway as well, because a pathway is
only ever resolved on an interactive run, so a team run that is neither
cancelled nor a dry run has already run the first look. Widening the condition
to "or the pathway is `team`" on the theory that the privacy narration follows
would admit only the runs where the first look did *not* run, and those are
exactly the runs that wrote nothing between the finale and here: the repeat
would land a few lines under the print it repeats, which is the same double
print the shared run summary was rejected for.

So a scripted `--yes` or `--dry-run` wizard run stays on the single finale
print and its output is unchanged, matching the attended-only rule
([LLP 0131](./0131-configure-phase.decision.md)), and so does an interactive
run cancelled at the backfill consent, whose summary is all that follows the
finale.

<a id="shorter"></a>**The repeat is shorter than the original.** The first
print explains the consequence in full; repeating that paragraph verbatim
reads as a bug rather than a reminder. The repeat carries only what a user
acts on: the client names and one `hyp detach --client <name>` line each. Both
prints share the dry-run tag rule the rest of the finale uses.

<a id="narration-stays-last"></a>**The privacy narration is still the last
words on the team path.** The repeat goes before it, not after
([LLP 0135 §first-look](./0135-install-experience-overhaul.design.md),
LLP 0100/0101). Seven lines of narration is a reminder that survives on one
screen; sixty lines of query output is not, and that is the difference this
decision turns on.

## Consequences {#consequences}

- `writeAttachedNotConfiguredReminder` is exported from
`src/core/cli/walkthrough.js` beside the finale's own
`writeAttachedNotConfiguredWarning`, which stays private to the finale.
- `writeWalkthroughRunSummary` is untouched. It still reports only what the
finale *did*, per
[LLP 0135](./0135-install-experience-overhaul.design.md); a warning is not
an action taken.
- A stranded attach on an attended wizard run is printed twice in one run, in
two places, deliberately. That is the cost of the finale's placement being
correct for the restart and wrong for the end of the run.
- The managed-host gap LLP 0185 recorded is narrowed, not closed: status still
says nothing on a joined host, but the run that creates the state now ends
on the warning instead of burying it.

## References

- Issue #614 (deferred review finding from PR #608), PR #608
- `src/core/cli/walkthrough.js` (`writeAttachedNotConfiguredReminder`),
`src/core/cli/wizard/index.js` (the closing sequence)
- LLP 0185, LLP 0135, LLP 0131, LLP 0100/0101
32 changes: 32 additions & 0 deletions src/core/cli/walkthrough.js
Original file line number Diff line number Diff line change
Expand Up @@ -1480,6 +1480,38 @@ function writeAttachedNotConfiguredWarning({ clients, stdout, dryRun }) {
for (const client of clients) stdout.write(` hyp detach --client ${client}\n`)
}

/**
* The closing repeat of the stranded-attach warning, for an entry point whose
* finale is not the last thing it writes.
*
* The finale's own warning stays exactly where LLP 0185 put it, before the
* daemon restart that is the point of no return. This is a second, compact
* print for the caller that keeps writing afterwards: the wizard follows the
* finale with a run summary, a first look that is roughly sixty lines of real
* query output, and on the team path a privacy narration, none of which pause,
* so on a real terminal the original warning is scrolled away by the time the
* run ends. It matters most on a managed host, where the finale's print is the
* only signal there is because `hyp status`'s mirror diagnostic is gated to
* hosts with no central layer (LLP 0185 #status-backstop).
*
* Only a caller that printed something substantial in between calls this.
* `runPickerWalkthrough` writes a short run summary and stops, so it keeps the
* single finale print and never repeats it onto the same screen.
*
* @ref LLP 0188#repeat-at-the-end [implements]: the repeat belongs to the caller whose own output buried the first print
* @param {{
* clients: string[],
* stdout: NodeJS.WritableStream | { write(chunk: string): unknown },
* dryRun: boolean,
* }} args
*/
export function writeAttachedNotConfiguredReminder({ clients, stdout, dryRun }) {
stdout.write('\n')
stdout.write(`${dryRun ? '(dry-run) ' : ''}Still attached, no longer collected: ${clients.join(', ')}\n`)
stdout.write('Their requests can start failing until you run:\n')
for (const client of clients) stdout.write(` hyp detach --client ${client}\n`)
}

/**
* Run the onboarding backfill step. For each picked client that has a
* registered backfill provider (intersection of `clientsPicked` and
Expand Down
48 changes: 45 additions & 3 deletions src/core/cli/wizard/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ import { discoverBundledPlugins } from '../../runtime/bundled.js'
import { buildPluginCatalog } from '../../plugin_catalog.js'
import { collectHypAwareStatus } from '../../daemon/status.js'
import { formatFirstSyncDeadline, readFirstSyncDeadline } from '../../usage-policy/first_sync_hold.js'
import { LOCAL_INSTALL_RETENTION_DAYS, runPickerFinale, writeWalkthroughRunSummary } from '../walkthrough.js'
import {
LOCAL_INSTALL_RETENTION_DAYS,
runPickerFinale,
writeAttachedNotConfiguredReminder,
writeWalkthroughRunSummary,
} from '../walkthrough.js'
import { LOGIN_ORG_SELECTION_MESSAGE } from '../remote_commands.js'
import { useColor } from '../stdio.js'
import { evaluateReturningGate, runWizardFork } from './fork.js'
Expand Down Expand Up @@ -212,20 +217,57 @@ export async function runInitWizard(opts) {
// still have to type. Attended and non-dry-run only: a scripted `--yes`
// install gets no extra output, and a dry run has no writes to look at.
// @ref LLP 0135#first-look [implements]: placed after the finale (backfill has landed) and before the privacy narration, which stays the last words
if (interactive && !cancelled && opts.finale?.dryRun !== true) {
const firstLookRan = interactive && !cancelled && opts.finale?.dryRun !== true
// Whether the block actually reached the screen, which is not the same
// question: the step is documented to degrade to a silent skip rather than
// fail a finished install (LLP 0135 #first-look), so an unregistered
// dataset, an unreadable cache, or a render that throws all leave
// `firstLookRan` true and stdout untouched. The closing repeat below reads
// this, not the gate, because what it needs to know is what was written.
let firstLookShown = false
if (firstLookRan) {
const notices = firstLookNoticeSink(opts.stderr)
await runWizardFirstLook({
const look = await runWizardFirstLook({
runner: opts.firstLook ?? firstLookRunnerFromCtx(opts.ctx, notices),
stdout: opts.stdout,
color: useColor(opts.stdout, opts.env),
})
firstLookShown = look.shown
// The abandoned queries from an expired deadline keep running and can
// still resolve with a withheld-row report. Close the sink so that
// report cannot land after the privacy narration below, which is
// documented to be the last thing on screen.
notices.close()
}

// The finale already named these, before the daemon restart that strands
// them (LLP 0185 #warn-do-not-detach). That print is no longer on screen by
// the time this run ends: the summary, then the first look's ~60 lines, then
// the narration below all follow it without a pause. Repeat it here, short,
// and only when this closing sequence actually wrote something, so the
// direct `runPickerWalkthrough` entry point (whose summary follows the
// finale with nothing in between) keeps its single print.
//
// `firstLookShown` is the whole condition, the team pathway included. It is
// read rather than `firstLookRan` because a first look that skipped itself
// wrote nothing, and rather than `pathway`, because a `pathway` is only ever
// resolved on an interactive run: a team run that is neither cancelled nor a
// dry run has already run the first look, so `|| pathway === 'team'` would
// widen this to exactly the runs where the first look did *not* run
// (cancelled at the backfill consent, or a dry run). Every run either
// condition would add wrote nothing between the finale and here, so the
// repeat would land a few lines under the print it repeats.
// @ref LLP 0188#when [constrained-by]: nothing written in between, no repeat
// @ref LLP 0188#repeat-at-the-end [implements]: the wizard repeats what its own closing output buried
const stranded = finaleSummary?.attachedNotConfigured ?? []
if (stranded.length > 0 && firstLookShown) {
writeAttachedNotConfiguredReminder({
clients: stranded,
stdout: opts.stdout,
dryRun: opts.finale?.dryRun === true,
})
}

// The wizard's last words on the team pathway: when the first upload
// happens and that nothing has shipped yet (LLP 0100/0101, narration
// only - the hold itself was written by the join lane's login).
Expand Down
115 changes: 115 additions & 0 deletions test/core/cli/wizard/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,121 @@ test('runInitWizard: a non-interactive or dry run skips the first look', async (
assert.equal(dry.seen.length, 0)
})

// --- the stranded-attach warning's closing repeat (LLP 0188) ---

/**
* A finale summary that reports clients this run left attached but no longer
* collects. The finale itself printed the full warning before the daemon
* restart; this is what it hands back for the closing repeat to read.
*
* @param {string[]} clients
*/
function strandedFinale(clients) {
return /** @type {any} */ ({
daemonInstall: { skipped: true, dryRun: false },
globalInstall: { skipped: true, installed: false },
attach: [],
skillsInstalled: [],
agentsInstalled: [],
daemonRestart: { skipped: true, dryRun: false, ok: false },
backfill: [],
attachedNotConfigured: clients,
})
}

// The finale names the stranded clients before the daemon restart (LLP 0185
// #warn-do-not-detach) and then the wizard writes the run summary, the first
// look's ~60 lines, and the privacy narration on top of it, so by the time an
// attended run ends the warning has scrolled away. On a managed host it is
// the only signal there is, because `hyp status`'s mirror diagnostic is gated
// to hosts with no central layer.
// @ref LLP 0188#repeat-at-the-end [tests]: the wizard repeats what its own closing output buried
test('runInitWizard: an attended run repeats the stranded-attach warning after the first look', async () => {
const home = await tmpHome()
await writeFirstSyncHoldMarker({ stateDir: path.join(home, '.hyp', 'hypaware') })
const stub = firstLookStub(
[{ provider: 'anthropic', model: 'claude-opus-5', input_tokens: 400, cached_tokens: 4000, output_tokens: 40 }],
[{ date: '2026-07-24', sessions: 3, input_tokens: 400, cached_tokens: 4000, output_tokens: 40 }]
)
const { opts, stdout } = wizardOpts(home, {
fork: async () => 'team',
firstLook: stub.runner,
finaleRunner: async () => strandedFinale(['codex']),
})
await runInitWizard(opts)
const text = stdout.text()

// The names and the one command that clears each, not a bare mention.
assert.match(text, /Still attached, no longer collected: codex/, text)
assert.match(text, /hyp detach --client codex/, text)
// Past the block that buried the finale's own print.
assert.ok(text.indexOf('First look') >= 0, text)
assert.ok(text.indexOf('hyp detach --client codex') > text.indexOf('First look'), text)
// And still ahead of the privacy narration, which stays the last words.
assert.ok(
text.indexOf('hyp detach --client codex') < text.indexOf('Nothing has been uploaded yet'),
text
)
})

// The repeat exists because the wizard's closing sequence buries the finale's
// print. A scripted run writes nothing between the two, so repeating there
// would be the double-print on one screen the shared run summary would have
// caused. Its output stays byte-identical to what the finale alone produced.
// @ref LLP 0188#when [tests]: no closing sequence, no repeat
test('runInitWizard: a scripted run does not repeat the stranded-attach warning', async () => {
const { opts, stdout } = wizardOpts(await tmpHome(), {
picks: { sources: ['claude'], exportChoice: 'local-parquet', retentionDays: 30 },
finaleRunner: async () => strandedFinale(['codex']),
})
await runInitWizard(opts)
assert.doesNotMatch(stdout.text(), /hyp detach --client/, stdout.text())
})

// A cancel at the backfill consent skips the first look, so the run summary is
// the only thing between the finale's own warning (which the finale prints
// before its restart block, cancelled or not) and the end of the run. The team
// pathway is not on its own a reason to repeat: a pathway is only resolved on
// an interactive run, so an uncancelled non-dry team run has already run the
// first look, and the runs a `pathway === 'team'` clause would add are exactly
// the ones with nothing in between.
// @ref LLP 0188#when [tests]: a cancelled team run buried nothing, so it does not repeat
test('runInitWizard: a run cancelled at the finale does not repeat the stranded-attach warning', async () => {
const home = await tmpHome()
await writeFirstSyncHoldMarker({ stateDir: path.join(home, '.hyp', 'hypaware') })
const { opts, stdout } = wizardOpts(home, {
fork: async () => 'team',
finaleRunner: async () => ({ ...strandedFinale(['codex']), cancelled: true }),
})
const result = await runInitWizard(opts)
assert.equal(result.cancelled, true)
assert.doesNotMatch(stdout.text(), /hyp detach --client/, stdout.text())
})

// The first look is documented to degrade to a silent skip rather than fail a
// finished install (LLP 0135 #first-look): an unregistered dataset, an
// unreadable cache, or a render that throws all leave an attended run that
// attempted the block and printed none of it. The gate that admits the repeat
// is therefore the block having reached the screen, not the run having tried:
// on a skip the finale's own print is still the last thing above the summary,
// and repeating under it would be the same-screen double print.
// @ref LLP 0188#when [tests]: a first look that printed nothing buried nothing
test('runInitWizard: an attended run whose first look skips itself does not repeat the stranded-attach warning', async () => {
const home = await tmpHome()
await writeFirstSyncHoldMarker({ stateDir: path.join(home, '.hyp', 'hypaware') })
const { opts, stdout } = wizardOpts(home, {
fork: async () => 'team',
// The shape `firstLookRunnerFromCtx` yields when the overview dataset is
// not registered: the step returns `{ shown: false }` without writing.
firstLook: { hasDataset: () => false, async run() { return { columns: [], rows: [] } } },
finaleRunner: async () => strandedFinale(['codex']),
})
await runInitWizard(opts)
const text = stdout.text()
assert.doesNotMatch(text, /First look/, text)
assert.doesNotMatch(text, /hyp detach --client/, text)
})

test('runInitWizard: team pathway with a live first-sync hold narrates the deadline', async () => {
const home = await tmpHome()
const stateDir = path.join(home, '.hyp', 'hypaware')
Expand Down
Loading
Loading