Skip to content
5 changes: 5 additions & 0 deletions bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,11 @@ function runWizard(
} catch (error) {
analytics.captureException(error as Error);
}
// tui.unmount() drains any post-exit message stashed via
// setPostExitMessage(...) to scrollback as part of its cleanup
// (see start-tui.ts + lib/post-exit-message.ts), so any
// copy-paste-ready prompt the workflow stashed lands in the
// user's terminal regardless of which screen exits the process.
tui.unmount();
process.exit(0);
} catch (err) {
Expand Down
46 changes: 46 additions & 0 deletions src/lib/__tests__/post-exit-message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { buildSession } from '../wizard-session';
import {
POST_EXIT_MESSAGE_KEY,
getPostExitMessage,
setPostExitMessage,
} from '../post-exit-message';

describe('post-exit-message accessors', () => {
it('round-trips set + get on the same session', () => {
const session = buildSession({});
setPostExitMessage(session, 'hello terminal scrollback');
expect(getPostExitMessage(session)).toBe('hello terminal scrollback');
});

it('returns undefined when nothing has been stashed', () => {
expect(getPostExitMessage(buildSession({}))).toBeUndefined();
});

it('returns undefined when frameworkContext holds a non-string at the key', () => {
// Defense-in-depth: the type guard rejects bogus shapes rather than
// returning garbage to the cleanup printer.
const session = buildSession({});
session.frameworkContext[POST_EXIT_MESSAGE_KEY] = { not: 'a string' };
expect(getPostExitMessage(session)).toBeUndefined();
});

it('survives shallow setKey clones of the top-level session', () => {
// The whole point of using frameworkContext: agent-runner.ts mutates
// session.outroData on a stale reference (the atom replaces session
// via setKey during the run), and the mutation goes to a stranded
// object. frameworkContext is shared by reference across those
// setKey shallow-spreads, so a direct mutation on it survives a
// top-level replacement of the session — which is exactly what
// happens in production. Simulate that here.
const original = buildSession({});
setPostExitMessage(original, 'lives in shared framework context');

// Simulate setKey: a NEW top-level session object that shallow-copies
// each top-level key from the original (so frameworkContext is the
// SAME reference as before).
const replaced = { ...original, lastStatus: 'something changed' };
expect(getPostExitMessage(replaced)).toBe(
'lives in shared framework context',
);
});
});
32 changes: 32 additions & 0 deletions src/lib/post-exit-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Stash text to print to the user's scrollback after the wizard exits.
* Read by `start-tui.ts`'s cleanup handler, AFTER `releaseTerminal()` —
* so the message survives any exit path (bin.ts unmount, screens that
* call `process.exit` directly, error paths).
*
* Why frameworkContext (not session.outroData):
* `agent-runner.ts` mutates `session.outroData` on a STALE session
* reference — by the time the mutation happens, `setKey` calls during
* the agent run have replaced the atom's top-level session, so the
* write goes to a stranded object. `frameworkContext` is the same
* reference across `setKey` shallow-spreads, so a direct mutation on
* it survives (until anyone calls `store.setFrameworkContext`, which
* clones it). Same pattern as
* `posthog-integration/handoff.ts`'s handoff-status accessor.
*/

import type { WizardSession } from './wizard-session.js';

export const POST_EXIT_MESSAGE_KEY = 'pendingPostExitMessage';

export function setPostExitMessage(
session: WizardSession,
message: string,
): void {
session.frameworkContext[POST_EXIT_MESSAGE_KEY] = message;
}

export function getPostExitMessage(session: WizardSession): string | undefined {
const v = session.frameworkContext?.[POST_EXIT_MESSAGE_KEY];
return typeof v === 'string' ? v : undefined;
}
Loading