diff --git a/llp/0185-unpicked-client-stays-attached.decision.md b/llp/0185-unpicked-client-stays-attached.decision.md
index d70898bc..7486c705 100644
--- a/llp/0185-unpicked-client-stays-attached.decision.md
+++ b/llp/0185-unpicked-client-stays-attached.decision.md
@@ -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
@@ -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.
+
**"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
diff --git a/llp/0188-stranded-attach-warning-repeats-at-the-end.decision.md b/llp/0188-stranded-attach-warning-repeats-at-the-end.decision.md
new file mode 100644
index 00000000..7e1459e1
--- /dev/null
+++ b/llp/0188-stranded-attach-warning-repeats-at-the-end.decision.md
@@ -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}
+
+**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 ` 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.
+
+**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.
+
+**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 ` line each. Both
+prints share the dry-run tag rule the rest of the finale uses.
+
+**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
diff --git a/src/core/cli/walkthrough.js b/src/core/cli/walkthrough.js
index 0793c341..a5bcf128 100644
--- a/src/core/cli/walkthrough.js
+++ b/src/core/cli/walkthrough.js
@@ -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
diff --git a/src/core/cli/wizard/index.js b/src/core/cli/wizard/index.js
index 119e758b..ec5e103f 100644
--- a/src/core/cli/wizard/index.js
+++ b/src/core/cli/wizard/index.js
@@ -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'
@@ -212,13 +217,22 @@ 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
@@ -226,6 +240,34 @@ export async function runInitWizard(opts) {
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).
diff --git a/test/core/cli/wizard/index.test.js b/test/core/cli/wizard/index.test.js
index ad1b19f4..25115218 100644
--- a/test/core/cli/wizard/index.test.js
+++ b/test/core/cli/wizard/index.test.js
@@ -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')
diff --git a/test/core/walkthrough-unpicked-attached-clients.test.js b/test/core/walkthrough-unpicked-attached-clients.test.js
index ff6ddcea..8674ca9b 100644
--- a/test/core/walkthrough-unpicked-attached-clients.test.js
+++ b/test/core/walkthrough-unpicked-attached-clients.test.js
@@ -89,6 +89,13 @@ test('the finale names a still-attached client the new config no longer collects
const out = stdout.text()
assert.match(out, /codex/, out)
assert.match(out, /hyp detach --client codex/, out)
+ // Exactly once. This entry point writes only its short run summary after the
+ // finale, so the warning is still on screen when the run ends and repeating
+ // it (the shape an echo from the shared `writeWalkthroughRunSummary` would
+ // take) would print the same paragraph twice within a few lines of itself.
+ // @ref LLP 0188#repeat-at-the-end [tests]: the repeat belongs to the caller that buried the first print, and this one does not
+ assert.equal(out.match(/hyp detach --client codex/g)?.length, 1, out)
+ assert.equal(out.match(/Still attached, no longer collected/g)?.length, 1, out)
})
test('a picked client that stays configured draws no stranded-attach warning', async () => {