From b40019fd0f683645a51d45330e93a1bb46d890d7 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Wed, 9 Sep 2026 12:01:52 -0400 Subject: [PATCH] feat: add mint failure spell-book handoff --- .../__tests__/wizard-ci-driver.test.ts | 22 ++ e2e-harness/action-registry.ts | 7 + src/lib/__tests__/wizard-spellbook.test.ts | 252 ++++++++++++++++++ src/lib/agent/__tests__/gateway-auth.test.ts | 115 ++++++++ src/lib/agent/agent-interface.ts | 4 +- src/lib/agent/gateway-auth.ts | 31 +++ src/lib/agent/mcp-prompt-streaming.ts | 4 +- src/lib/agent/runner/harness/pi/index.ts | 5 +- src/lib/agent/runner/harness/pi/task.ts | 5 +- src/lib/agent/runner/shared/bootstrap.ts | 11 +- src/lib/wizard-session.ts | 32 +++ src/lib/wizard-spellbook.ts | 132 +++++++++ src/ui/logging-ui.ts | 7 + src/ui/mint-failure.ts | 16 ++ src/ui/tui/__tests__/exit-line.test.ts | 22 ++ src/ui/tui/__tests__/router.test.ts | 19 ++ src/ui/tui/exit-line.ts | 11 + src/ui/tui/ink-ui.ts | 6 + src/ui/tui/playground/PlaygroundApp.tsx | 6 + .../tui/playground/demos/MintFailureDemo.tsx | 53 ++++ src/ui/tui/router.ts | 4 + src/ui/tui/screen-registry.tsx | 18 ++ src/ui/tui/screen-sequences.ts | 1 + src/ui/tui/screens/MintFailureScreen.tsx | 131 +++++++++ src/ui/tui/store.ts | 10 +- src/ui/wizard-ui.ts | 7 +- 26 files changed, 917 insertions(+), 14 deletions(-) create mode 100644 src/lib/__tests__/wizard-spellbook.test.ts create mode 100644 src/lib/agent/__tests__/gateway-auth.test.ts create mode 100644 src/lib/agent/gateway-auth.ts create mode 100644 src/lib/wizard-spellbook.ts create mode 100644 src/ui/mint-failure.ts create mode 100644 src/ui/tui/playground/demos/MintFailureDemo.tsx create mode 100644 src/ui/tui/screens/MintFailureScreen.tsx diff --git a/e2e-harness/__tests__/wizard-ci-driver.test.ts b/e2e-harness/__tests__/wizard-ci-driver.test.ts index f5368d6a9..39937ceec 100644 --- a/e2e-harness/__tests__/wizard-ci-driver.test.ts +++ b/e2e-harness/__tests__/wizard-ci-driver.test.ts @@ -22,6 +22,8 @@ import { Program } from '@lib/programs/program-registry'; import { WizardCiDriver, UnknownActionError } from '../wizard-ci-driver'; import { ACTION_REGISTRY, NO_ACTION_SCREENS } from '../action-registry'; import { SOURCE_MAPS_CONTEXT_KEYS } from '@lib/programs/error-tracking-upload-source-maps/index'; +import { ErrorCodes } from '@lib/errors/codes'; +import { OutroKind } from '@lib/wizard-session'; function freshStore(): WizardStore { const store = new WizardStore(Program.PostHogIntegration); @@ -45,6 +47,26 @@ const cleanReadiness = { }; describe('WizardCiDriver — full integration flow', () => { + it('keeps a late mint failure open until it is dismissed again', async () => { + const store = freshStore(); + const ui = new InkUI(store); + const driver = new WizardCiDriver(store); + store.setOutroDismissed(); + ui.outroError({ + kind: OutroKind.Error, + errorCode: ErrorCodes.GatewayMintFailed, + }); + expect(driver.readState().currentScreen).toBe(ScreenId.MintFailure); + expect(store.session.outroDismissed).toBe(false); + const dismissed = vi.fn(); + const waiting = ui.waitForOutroDismissed().then(dismissed); + await Promise.resolve(); + expect(dismissed).not.toHaveBeenCalled(); + driver.performAction('dismiss_outro'); + await waiting; + expect(dismissed).toHaveBeenCalledOnce(); + }); + it('walks intro → setup → run → outro → mcp → slack → keep-skills', () => { const store = freshStore(); const driver = new WizardCiDriver(store); diff --git a/e2e-harness/action-registry.ts b/e2e-harness/action-registry.ts index 71918cdca..784ee7ade 100644 --- a/e2e-harness/action-registry.ts +++ b/e2e-harness/action-registry.ts @@ -207,6 +207,13 @@ export const ACTION_REGISTRY: Partial> = { apply: (store) => store.setOutroDismissed(), }, ], + [ScreenId.MintFailure]: [ + { + id: 'dismiss_outro', + description: 'Dismiss the mint failure so the wizard can exit.', + apply: (store) => store.setOutroDismissed(), + }, + ], // ── MCP install ─────────────────────────────────────────────────────── [ScreenId.Mcp]: [ diff --git a/src/lib/__tests__/wizard-spellbook.test.ts b/src/lib/__tests__/wizard-spellbook.test.ts new file mode 100644 index 000000000..e3b98ab2e --- /dev/null +++ b/src/lib/__tests__/wizard-spellbook.test.ts @@ -0,0 +1,252 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { Integration } from '../constants'; +import type { ProgramConfig } from '../programs/program-step'; +import { getProgramConfig, Program } from '../programs/program-registry'; +import { buildAgentRunContext, buildSession } from '../wizard-session'; +import { writeWizardSpellbook } from '../wizard-spellbook'; +import { downloadSkill, fetchSkillMenu } from '../wizard-tools/tools'; +import { InkUI } from '@ui/tui/ink-ui'; +import { createServices } from '@ui/tui/screen-registry'; +import { WizardStore } from '@ui/tui/store'; + +vi.mock('../wizard-tools/tools', async (importOriginal) => ({ + ...(await importOriginal()), + fetchSkillMenu: vi.fn(), + downloadSkill: vi.fn(), +})); + +const program: ProgramConfig = { + id: 'example-setup', + description: 'Set up the example integration.', + agentFlow: 'example-flow', + steps: [], +}; + +const skill = { + id: 'example-skill', + name: 'Example skill', + downloadUrl: 'https://example.com/skill.zip', +}; + +describe('writeWizardSpellbook', () => { + let installDir: string; + + beforeEach(async () => { + installDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'wizard-spellbook-test-'), + ); + vi.clearAllMocks(); + vi.mocked(fetchSkillMenu).mockResolvedValue(null); + vi.mocked(downloadSkill).mockResolvedValue({ success: true }); + }); + + afterEach(async () => { + await fs.rm(installDir, { recursive: true, force: true }); + }); + + it('leaves useful instructions without credentials or a successful network request', async () => { + const session = buildSession({ installDir }); + session.apiKey = 'personal-api-secret'; + session.email = 'private-user@example.com'; + session.frameworkContext = { secret: 'framework-secret' }; + session.credentials = { + accessToken: 'access-token-secret', + refreshToken: 'refresh-token-secret', + } as typeof session.credentials; + const dynamicRun = vi.fn(); + + const result = await writeWizardSpellbook(session, { + ...program, + run: dynamicRun, + }); + const readme = await fs.readFile(result.path, 'utf8'); + + expect(result.skillsIncluded).toBe(false); + expect(readme).toContain(program.description); + expect(readme).toContain('https://posthog.com/docs'); + expect(readme).toContain('No matching skills could be downloaded'); + expect(readme).not.toMatch(/secret|private-user/); + expect(downloadSkill).not.toHaveBeenCalled(); + expect(dynamicRun).not.toHaveBeenCalled(); + }); + + it('downloads the selected skill without gateway triage and links the local instructions', async () => { + vi.mocked(fetchSkillMenu).mockResolvedValue({ + categories: { skills: [skill] }, + }); + const session = buildSession({ installDir }); + session.skillId = skill.id; + + const result = await writeWizardSpellbook(session, program); + + expect(downloadSkill).toHaveBeenCalledExactlyOnceWith( + skill, + path.dirname(result.path), + { + skillsRoot: 'skills', + triage: undefined, + }, + ); + expect(result.skillsIncluded).toBe(true); + expect(await fs.readFile(result.path, 'utf8')).toContain( + '[example-skill](skills/example-skill/SKILL.md)', + ); + }); + + it('leaves a composed integration handoff in its child project using its own program', async () => { + const store = new WizardStore(Program.SelfDriving); + store.session = buildSession({ installDir }); + const child = buildSession({ installDir: path.join(installDir, 'app') }); + child.skillId = skill.id; + child.apiKey = 'private-api-key'; + child.frameworkContext = { secret: 'private-framework-value' }; + vi.mocked(fetchSkillMenu).mockResolvedValue({ + categories: { skills: [skill] }, + }); + const context = buildAgentRunContext(child, Program.PostHogIntegration); + new InkUI(store).setAgentRunContext(context); + + const result = await createServices(store).leaveSpellbook(); + const readme = await fs.readFile(result.path, 'utf8'); + + expect( + result.path.startsWith( + path.join(child.installDir, '.posthog') + path.sep, + ), + ).toBe(true); + expect(readme).toContain( + getProgramConfig(Program.PostHogIntegration).description, + ); + expect(readme).toContain(`Wizard program: ${Program.PostHogIntegration}`); + expect(readme).not.toContain(`Wizard program: ${Program.SelfDriving}`); + expect(JSON.stringify(context)).not.toContain('private-'); + expect(downloadSkill).toHaveBeenCalledExactlyOnceWith( + skill, + path.dirname(result.path), + { + skillsRoot: 'skills', + triage: undefined, + }, + ); + await expect(fs.stat(path.join(installDir, '.posthog'))).rejects.toThrow(); + }); + + it('uses program metadata and the detected framework to select default variants', async () => { + const session = buildSession({ installDir }); + session.integration = Integration.nextjs; + const entry = ( + id: string, + group: string, + framework?: string, + isDefault?: boolean, + ) => ({ + ...skill, + id, + group, + framework, + default: isDefault, + }); + const integration = entry( + 'integration-nextjs', + 'integration', + 'nextjs', + true, + ); + const setup = entry( + 'example-flow-setup-nextjs', + 'example-flow-setup', + 'nextjs', + true, + ); + const shared = entry('example-flow-finish', 'example-flow-finish'); + vi.mocked(fetchSkillMenu).mockResolvedValue({ + categories: { + integration: [ + entry('integration-nextjs-alt', 'integration', 'nextjs'), + integration, + ], + steps: [ + setup, + shared, + entry('example-flow-setup-django', 'example-flow-setup', 'django'), + entry('unrelated-skill', 'other-flow', 'nextjs'), + entry('../escaped', 'example-flow-unsafe'), + ], + }, + }); + + await writeWizardSpellbook(session, program); + + expect( + vi.mocked(downloadSkill).mock.calls.map(([selected]) => selected.id), + ).toEqual([setup.id, shared.id]); + }); + + it('resolves a bare framework skill to its integration reference without using it for other programs', async () => { + const session = buildSession({ installDir }); + session.integration = Integration.nextjs; + session.skillId = Integration.nextjs; + const reference = { + ...skill, + id: 'integration-nextjs-app-router', + group: 'integration', + framework: 'nextjs', + default: true, + }; + vi.mocked(fetchSkillMenu).mockResolvedValue({ + categories: { integration: [reference] }, + }); + + await writeWizardSpellbook(session, program); + expect(downloadSkill).toHaveBeenCalledTimes(1); + expect(vi.mocked(downloadSkill).mock.calls[0][0]).toEqual(reference); + + vi.mocked(downloadSkill).mockClear(); + await writeWizardSpellbook(session, { + ...program, + skillId: 'missing-audit-skill', + }); + expect(downloadSkill).not.toHaveBeenCalled(); + }); + + it('removes incomplete or rejected skills and reports a docs-only handoff', async () => { + const session = buildSession({ installDir }); + session.skillId = skill.id; + vi.mocked(fetchSkillMenu).mockResolvedValue({ + categories: { skills: [skill] }, + }); + vi.mocked(downloadSkill).mockImplementation(async (_skill, directory) => { + const destination = path.join(directory, 'skills', skill.id); + await fs.mkdir(destination, { recursive: true }); + await fs.writeFile( + path.join(destination, 'SKILL.md'), + 'Partial unverified download', + ); + return { success: false }; + }); + + const result = await writeWizardSpellbook(session, program); + + expect(result.skillsIncluded).toBe(false); + expect(await fs.readFile(result.path, 'utf8')).not.toContain('(skills/'); + await expect( + fs.stat(path.join(path.dirname(result.path), 'skills', skill.id)), + ).rejects.toThrow(); + }); + + it('preserves earlier handoffs and existing project instructions', async () => { + const existing = path.join(installDir, 'AGENTS.md'); + await fs.writeFile(existing, 'Project instructions'); + const session = buildSession({ installDir }); + const first = await writeWizardSpellbook(session, program); + await fs.writeFile(first.path, 'Earlier handoff'); + + const second = await writeWizardSpellbook(session, program); + + expect(second.path).not.toBe(first.path); + expect(await fs.readFile(first.path, 'utf8')).toBe('Earlier handoff'); + expect(await fs.readFile(existing, 'utf8')).toBe('Project instructions'); + }); +}); diff --git a/src/lib/agent/__tests__/gateway-auth.test.ts b/src/lib/agent/__tests__/gateway-auth.test.ts new file mode 100644 index 000000000..960cabb51 --- /dev/null +++ b/src/lib/agent/__tests__/gateway-auth.test.ts @@ -0,0 +1,115 @@ +import { requireGatewayAuth } from '@lib/agent/gateway-auth'; +import { + gatewayAuth, + GatewayMintFailed, + GatewayMintRefused, + type GatewayAuth, +} from '@lib/gateway-session'; +import type { HostResolution } from '@lib/host-resolution'; +import { wizardAbort } from '@utils/wizard-abort'; + +vi.mock('@lib/gateway-session', async (importOriginal) => ({ + ...(await importOriginal()), + gatewayAuth: vi.fn(), +})); + +vi.mock('@utils/wizard-abort', async (importOriginal) => ({ + ...(await importOriginal()), + wizardAbort: vi.fn(), +})); + +const host = { apiHost: 'https://us.posthog.com' } as HostResolution; +const auth: GatewayAuth = { + gatewayUrl: 'https://gateway.us.posthog.com', + token: 'phe_test', + refreshAtMs: Date.now() + 3600_000, +}; + +describe('requireGatewayAuth', () => { + beforeEach(() => { + vi.mocked(gatewayAuth).mockReset(); + vi.mocked(wizardAbort).mockReset(); + }); + + it('returns a usable credential unchanged', async () => { + vi.mocked(gatewayAuth).mockResolvedValue(auth); + + await expect( + requireGatewayAuth(host, 'pha_test', 'integration'), + ).resolves.toBe(auth); + expect(gatewayAuth).toHaveBeenCalledWith(host, 'pha_test', 'integration'); + expect(wizardAbort).not.toHaveBeenCalled(); + }); + + it.each([ + new GatewayMintRefused(403, 'Access denied', 'blocked'), + new GatewayMintFailed('Mint unavailable'), + ])('preserves the coded failure in the fatal outro: $name', async (error) => { + const exit = new Error('Wizard exited'); + vi.mocked(gatewayAuth).mockRejectedValue(error); + vi.mocked(wizardAbort).mockRejectedValue(exit); + + await expect( + requireGatewayAuth(host, 'pha_test', 'integration'), + ).rejects.toBe(exit); + expect(wizardAbort).toHaveBeenCalledExactlyOnceWith({ + message: error.message, + error, + }); + }); + + it('parks concurrent callers behind one fatal outro until it exits', async () => { + const error = new GatewayMintRefused(429, 'Daily limit reached'); + const exit = new Error('Wizard exited'); + let exitAbort: (error: Error) => void; + const abort = new Promise((_, reject) => { + exitAbort = reject; + }); + vi.mocked(gatewayAuth).mockRejectedValue(error); + vi.mocked(wizardAbort).mockReturnValue(abort); + + const calls = [ + requireGatewayAuth(host, 'pha_test', 'integration'), + requireGatewayAuth(host, 'pha_test', 'integration'), + ]; + const settled = vi.fn(); + const results = Promise.allSettled(calls).then(settled); + await vi.waitFor(() => expect(wizardAbort).toHaveBeenCalledTimes(1)); + expect(settled).not.toHaveBeenCalled(); + + exitAbort!(exit); + await results; + expect(settled).toHaveBeenCalledExactlyOnceWith([ + { status: 'rejected', reason: exit }, + { status: 'rejected', reason: exit }, + ]); + }); + + it('also aborts a failed renewal after an earlier mint succeeded', async () => { + const error = new GatewayMintFailed('Renewal unavailable'); + const exit = new Error('Wizard exited'); + vi.mocked(gatewayAuth) + .mockResolvedValueOnce(auth) + .mockRejectedValueOnce(error); + vi.mocked(wizardAbort).mockRejectedValue(exit); + + await requireGatewayAuth(host, 'pha_test', 'integration'); + await expect( + requireGatewayAuth(host, 'pha_test', 'integration'), + ).rejects.toBe(exit); + expect(wizardAbort).toHaveBeenCalledExactlyOnceWith({ + message: error.message, + error, + }); + }); + + it('leaves unrelated errors to the existing caller handling', async () => { + const error = new Error('Unexpected failure'); + vi.mocked(gatewayAuth).mockRejectedValue(error); + + await expect( + requireGatewayAuth(host, 'pha_test', 'integration'), + ).rejects.toBe(error); + expect(wizardAbort).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 815498a25..4e4578fa4 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -32,10 +32,10 @@ import { createCustomHeaders } from '@utils/custom-headers'; import type { HostResolution } from '@lib/host-resolution'; import { buildWizardPropertiesBlob, - gatewayAuth, isPastRefresh, type GatewayAuth, } from '@lib/gateway-session'; +import { requireGatewayAuth } from '@lib/agent/gateway-auth'; import { evaluateBashCommand } from './bash-fence'; import { createWizardToolsServer, WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; import { @@ -539,7 +539,7 @@ export async function initializeAgent( // Disable experimental betas (like input_examples) the gateway doesn't support. process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = 'true'; const currentGatewayAuth = () => - gatewayAuth(config.host, config.posthogApiKey, config.programId); + requireGatewayAuth(config.host, config.posthogApiKey, config.programId); const auth = await currentGatewayAuth(); const gatewayUrl = auth.gatewayUrl; process.env.ANTHROPIC_BASE_URL = gatewayUrl; diff --git a/src/lib/agent/gateway-auth.ts b/src/lib/agent/gateway-auth.ts new file mode 100644 index 000000000..254cab7af --- /dev/null +++ b/src/lib/agent/gateway-auth.ts @@ -0,0 +1,31 @@ +import { + gatewayAuth, + GatewayMintFailed, + GatewayMintRefused, + type GatewayAuth, +} from '@lib/gateway-session'; +import { wizardAbort } from '@utils/wizard-abort'; + +const pendingAborts = new WeakMap>(); + +/** End the wizard before a harness or security hook can swallow a mint failure. */ +export async function requireGatewayAuth( + ...args: Parameters +): Promise { + try { + return await gatewayAuth(...args); + } catch (error) { + if ( + !(error instanceof GatewayMintRefused) && + !(error instanceof GatewayMintFailed) + ) { + throw error; + } + let abort = pendingAborts.get(error); + if (!abort) { + abort = wizardAbort({ message: error.message, error }); + pendingAborts.set(error, abort); + } + return abort; + } +} diff --git a/src/lib/agent/mcp-prompt-streaming.ts b/src/lib/agent/mcp-prompt-streaming.ts index 1b9e55182..6fabf0c67 100644 --- a/src/lib/agent/mcp-prompt-streaming.ts +++ b/src/lib/agent/mcp-prompt-streaming.ts @@ -16,7 +16,7 @@ import type { AgentChunk } from '@ui/tui/services/mcp-suggested-prompts-services import type { Credentials } from '@lib/wizard-session'; import { DEFAULT_AGENT_MODEL, WIZARD_USER_AGENT } from '@lib/constants'; import { logToFile } from '@utils/debug'; -import { gatewayAuth } from '@lib/gateway-session'; +import { requireGatewayAuth } from '@lib/agent/gateway-auth'; import { buildAgentEnv, buildRunTags } from '@lib/agent/agent-interface'; import { sanitizeAgentSubprocessEnv } from '@lib/agent/agent-env-isolation'; import { createIsolatedAgentConfigDir } from '@lib/agent/stored-login'; @@ -226,7 +226,7 @@ export async function* runMcpPromptViaSdk(args: { // The url and the bearer are one unit: a run must take both from the same // mint. - const auth = await gatewayAuth( + const auth = await requireGatewayAuth( credentials.host, credentials.accessToken, args.programId, diff --git a/src/lib/agent/runner/harness/pi/index.ts b/src/lib/agent/runner/harness/pi/index.ts index a6028ff0e..acd27c1cb 100644 --- a/src/lib/agent/runner/harness/pi/index.ts +++ b/src/lib/agent/runner/harness/pi/index.ts @@ -27,7 +27,8 @@ import { AgentErrorType } from '@lib/agent/agent-interface'; import { AgentSignals, REMARK_INSTRUCTION } from '@lib/agent/signals'; import { AgentOutputSignals } from '@lib/agent/output-signals'; import { assembleCommandments } from '../../switchboard/commandments'; -import { gatewayAuth, type GatewayAuth } from '@lib/gateway-session'; +import type { GatewayAuth } from '@lib/gateway-session'; +import { requireGatewayAuth } from '@lib/agent/gateway-auth'; import { buildGatewayProvider, GATEWAY_PROVIDER, @@ -253,7 +254,7 @@ export const piBackend: AgentHarness = { // orchestrator's per-task sessions (gateway.ts). gatewayAuth mints the // run's scoped token. const refreshAuth = () => - gatewayAuth( + requireGatewayAuth( boot.credentials.host, boot.credentials.accessToken, boot.programId, diff --git a/src/lib/agent/runner/harness/pi/task.ts b/src/lib/agent/runner/harness/pi/task.ts index a6e575def..8818b2bc9 100644 --- a/src/lib/agent/runner/harness/pi/task.ts +++ b/src/lib/agent/runner/harness/pi/task.ts @@ -36,7 +36,8 @@ import { AgentOutputSignals } from '@lib/agent/output-signals'; import { TaskStatus } from '../../sequence/orchestrator/queue'; import type { OrchestratorToolsContext } from '../../sequence/orchestrator/queue-tools'; import type { AgentResult, TaskRunInputs } from '../types'; -import { gatewayAuth, type GatewayAuth } from '@lib/gateway-session'; +import type { GatewayAuth } from '@lib/gateway-session'; +import { requireGatewayAuth } from '@lib/agent/gateway-auth'; import { buildGatewayProvider, GATEWAY_PROVIDER, @@ -220,7 +221,7 @@ export async function runPiTask(inputs: TaskRunInputs): Promise { } = sdk; const refreshAuth = () => - gatewayAuth( + requireGatewayAuth( boot.credentials.host, boot.credentials.accessToken, boot.programId, diff --git a/src/lib/agent/runner/shared/bootstrap.ts b/src/lib/agent/runner/shared/bootstrap.ts index 6c2bd794e..8a45899a5 100644 --- a/src/lib/agent/runner/shared/bootstrap.ts +++ b/src/lib/agent/runner/shared/bootstrap.ts @@ -7,13 +7,13 @@ * effects. Returns the values the arms still need. */ -import type { WizardSession } from '@lib/wizard-session'; +import { buildAgentRunContext, type WizardSession } from '@lib/wizard-session'; import { analytics } from '@utils/analytics'; import { getUI } from '@ui'; import { authenticate, refreshAccessTokenIfNeeded } from './authenticate'; import { maybeStampAiSdkDetected } from '@lib/programs/posthog-integration/detect'; import { createTriageLLMProvider } from '@lib/agent/triage-provider'; -import { gatewayAuth } from '@lib/gateway-session'; +import { requireGatewayAuth } from '@lib/agent/gateway-auth'; import { resolveHarness } from '../switchboard'; import { buildRunTags } from '@lib/agent/agent-interface'; import { @@ -92,6 +92,7 @@ export async function bootstrapProgram( // 1. Init logging + debug initLogFile(); session.skillId = config.skillId ?? config.integrationLabel; + getUI().setAgentRunContext(buildAgentRunContext(session, programConfig.id)); logToFile( `[agent-runner] START ${config.integrationLabel} build=${analytics.build}` + `${session.ci ? ' (non-interactive)' : ''}`, @@ -311,7 +312,11 @@ export async function bootstrapProgram( // readers re-resolve through the cache, which re-mints past the refresh // point. const currentGatewayAuth = () => - gatewayAuth(credentials.host, credentials.accessToken, programConfig.id); + requireGatewayAuth( + credentials.host, + credentials.accessToken, + programConfig.id, + ); await currentGatewayAuth(); return { diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index 40a704aba..c47cba212 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -18,6 +18,36 @@ import type { SettingsConflict } from './agent/claude-settings'; import type { ApiUser, ApiProject } from './api'; import type { HostResolution } from './host-resolution'; +export type AgentRunContext = Pick< + WizardSession, + 'installDir' | 'integration' | 'skillId' +> & { + programId: string; + frameworkConfig: { + metadata: Pick; + } | null; +}; + +export function buildAgentRunContext( + session: WizardSession, + programId: string, +): AgentRunContext { + return { + programId, + installDir: session.installDir, + integration: session.integration, + skillId: session.skillId, + frameworkConfig: session.frameworkConfig + ? { + metadata: { + name: session.frameworkConfig.metadata.name, + docsUrl: session.frameworkConfig.metadata.docsUrl, + }, + } + : null, + }; +} + export interface Credentials { accessToken: string; /** OAuth refresh token when the grant carried one; absent on CI api-key runs. */ @@ -448,6 +478,7 @@ export interface WizardSession { // Program metadata (set by runWizard in bin.ts) programLabel: string | null; skillId: string | null; + agentRunContext: AgentRunContext | null; // Resolved framework config (set after integration is known) frameworkConfig: FrameworkConfig | null; @@ -563,6 +594,7 @@ export function buildSession(args: { additionalFeatureQueue: [], programLabel: null, skillId: null, + agentRunContext: null, frameworkConfig: null, pendingQuestion: null, }; diff --git a/src/lib/wizard-spellbook.ts b/src/lib/wizard-spellbook.ts new file mode 100644 index 000000000..d4a8f8f21 --- /dev/null +++ b/src/lib/wizard-spellbook.ts @@ -0,0 +1,132 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { getSkillsBaseUrl, POSTHOG_DOCS_URL } from './constants'; +import type { ProgramConfig } from './programs/program-step'; +import type { AgentRunContext } from './wizard-session'; +import { + downloadSkill, + fetchSkillMenu, + type SkillEntry, + type SkillMenu, +} from './wizard-tools/tools'; + +export type WizardSpellbook = { path: string; skillsIncluded: boolean }; +type SpellbookSession = Omit; + +function selectSkills( + menu: SkillMenu, + session: SpellbookSession, + program: ProgramConfig, +): SkillEntry[] { + const entries = Object.values(menu.categories) + .flat() + .filter((entry) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id)); + const skillId = program.skillId ?? session.skillId; + const exact = entries.find((entry) => entry.id === skillId); + if (exact) return [exact]; + + const framework = session.integration; + const referenceGroup = skillId === framework ? 'integration' : skillId; + const references = entries.filter( + (entry) => entry.group === referenceGroup && entry.framework === framework, + ); + const reference = references.find((entry) => entry.default) ?? references[0]; + if (reference) return [reference]; + + const flow = program.agentFlow ?? program.id; + const groups = new Map(); + for (const entry of entries) { + const group = entry.group; + if ( + !group || + (group !== flow && !group.startsWith(`${flow}-`)) || + (entry.framework && entry.framework !== framework) + ) { + continue; + } + if (!groups.has(group) || entry.default) groups.set(group, entry); + } + return [...groups.values()]; +} + +function spellbookText( + session: SpellbookSession, + program: ProgramConfig, + skills: SkillEntry[], +): string { + const framework = session.frameworkConfig?.metadata; + const runDocs = + typeof program.run === 'object' ? program.run.docsUrl : undefined; + const docs = [ + ...new Set([framework?.docsUrl, runDocs, POSTHOG_DOCS_URL]), + ].filter((url): url is string => Boolean(url)); + + return [ + '# Wizard spell book', + '', + 'Complete this task in the project containing this spell book:', + '', + program.description, + '', + `Wizard program: ${program.id}`, + ...(framework ? [`Detected framework: ${framework.name}`] : []), + '', + 'Inspect the existing project and its instructions before making changes. Check what has already been done, complete the requested task within its stated scope, then run the relevant checks and explain any remaining manual steps.', + '', + 'Use your own coding agent and its authentication. The Wizard could not continue its inference session. No Wizard credentials are included; ask the user for any credentials the task requires and keep them out of source code.', + '', + '## Setup instructions', + '', + ...(skills.length + ? [ + 'Read the downloaded skills and their referenced files:', + '', + ...skills.map( + (skill) => `- [${skill.id}](skills/${skill.id}/SKILL.md)`, + ), + '', + 'Downloaded skills may cover only part of the task. Use the official documentation below to fill any gaps.', + ] + : [ + 'No matching skills could be downloaded. Use the official documentation below to complete the task.', + ]), + '', + '## Official documentation', + '', + ...docs.map((url) => `- ${url}`), + '', + ].join('\n'); +} + +export async function writeWizardSpellbook( + session: SpellbookSession, + program: ProgramConfig, +): Promise { + const parent = path.join(session.installDir, '.posthog'); + await fs.mkdir(parent, { recursive: true }); + const directory = await fs.mkdtemp(path.join(parent, 'wizard-spellbook-')); + const readme = path.join(directory, 'README.md'); + await fs.writeFile(readme, spellbookText(session, program, [])); + + const menu = await fetchSkillMenu(getSkillsBaseUrl(), { + timeoutMs: 10_000, + maxAttempts: 1, + }); + const installed: SkillEntry[] = []; + for (const skill of menu ? selectSkills(menu, session, program) : []) { + const result = await downloadSkill(skill, directory, { + skillsRoot: 'skills', + triage: undefined, + }); + if (result.success) { + installed.push(skill); + } else { + await fs.rm(path.join(directory, 'skills', skill.id), { + recursive: true, + force: true, + }); + } + } + await fs.writeFile(readme, spellbookText(session, program, installed)); + return { path: readme, skillsIncluded: installed.length > 0 }; +} diff --git a/src/ui/logging-ui.ts b/src/ui/logging-ui.ts index 30ff56dac..abdea874c 100644 --- a/src/ui/logging-ui.ts +++ b/src/ui/logging-ui.ts @@ -14,12 +14,14 @@ import { import type { SettingsConflict } from '@lib/agent/claude-settings'; import type { ApiUser } from '@lib/api'; import { OAUTH_TIMEOUT_MS } from '@lib/constants'; +import { isMintFailure, MINT_FAILURE_CONTACT } from './mint-failure'; import { type WizardReadinessResult, getBlockingServiceKeys, SERVICE_LABELS, } from '@lib/health-checks/readiness'; import type { + AgentRunContext, AskAnswers, Credentials, OutroData, @@ -40,6 +42,7 @@ export class LoggingUI implements WizardUI { console.log(`✖ ${data.message ?? 'Wizard aborted'}`); if (data.body) console.log(`│ ${data.body}`); if (data.docsUrl) console.log(`│ Docs: ${data.docsUrl}`); + if (isMintFailure(data)) console.log(`│ ${MINT_FAILURE_CONTACT}`); } waitForOutroDismissed(): Promise { @@ -95,6 +98,10 @@ export class LoggingUI implements WizardUI { console.log(`◇ ${message}`); } + setAgentRunContext(_context: AgentRunContext): void { + // The interactive handoff uses this context only in the TUI. + } + setDetectedFramework(label: string): void { console.log(`✔ Framework: ${label}`); } diff --git a/src/ui/mint-failure.ts b/src/ui/mint-failure.ts new file mode 100644 index 000000000..3b7bde973 --- /dev/null +++ b/src/ui/mint-failure.ts @@ -0,0 +1,16 @@ +import { ErrorCodes } from '@lib/errors/codes'; +import { OutroKind, type OutroData } from '@lib/wizard-session'; + +export const MINT_FAILURE_MESSAGE = + "The wizard's a little occupied right now, would you like the Wizard to leave it's spell book behind for your agent to complete the setup?"; + +export const MINT_FAILURE_CONTACT = + 'If you would really like to use the Wizard, please contact wizard@posthog.com.'; + +export function isMintFailure(data: OutroData | null | undefined): boolean { + return ( + data?.kind === OutroKind.Error && + (data.errorCode === ErrorCodes.GatewayMintRefused || + data.errorCode === ErrorCodes.GatewayMintFailed) + ); +} diff --git a/src/ui/tui/__tests__/exit-line.test.ts b/src/ui/tui/__tests__/exit-line.test.ts index 146f74af4..20b5ababe 100644 --- a/src/ui/tui/__tests__/exit-line.test.ts +++ b/src/ui/tui/__tests__/exit-line.test.ts @@ -1,6 +1,7 @@ import { getExitLine } from '@ui/tui/exit-line'; import { WizardStore, Program } from '@ui/tui/store'; import { OutroKind } from '@lib/wizard-session'; +import { ErrorCodes } from '@lib/errors/codes'; vi.mock('../../../utils/analytics.js', () => ({ analytics: { @@ -30,6 +31,27 @@ function setHudVisible(store: WizardStore, visible: boolean): void { } describe('getExitLine', () => { + it.each([ErrorCodes.GatewayMintRefused, ErrorCodes.GatewayMintFailed])( + 'keeps the failed setup handoff and contact in scrollback for %s', + (errorCode) => { + const handoffPrompt = + 'Read /project/.posthog/wizard-spellbook-123/README.md and finish setup.'; + const line = stripAnsi( + getExitLine( + storeWithOutro({ + kind: OutroKind.Error, + errorCode, + handoffPrompt, + }), + ), + ); + expect(line).toContain('Setup has not been completed.'); + expect(line.split('\n')).toContain(handoffPrompt); + expect(line).toContain('wizard@posthog.com'); + expect(line).not.toContain('successfully'); + }, + ); + it('echoes the handoff prompt on its own line so it survives in scrollback', () => { const prompt = 'Read `posthog-setup-report.md` and work through the checklist.'; diff --git a/src/ui/tui/__tests__/router.test.ts b/src/ui/tui/__tests__/router.test.ts index 5cbea8054..4a86b03c3 100644 --- a/src/ui/tui/__tests__/router.test.ts +++ b/src/ui/tui/__tests__/router.test.ts @@ -9,12 +9,31 @@ import { WizardReadiness } from '@lib/health-checks/readiness'; import { WizardRouter, ScreenId, Overlay, Program } from '@ui/tui/router'; import { Integration } from '@lib/constants'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; +import { ErrorCodes } from '@lib/errors/codes'; +import { PROGRAM_REGISTRY } from '@lib/programs/program-registry'; function baseWizardSession() { return buildSession({}); } describe('WizardRouter', () => { + it.each(PROGRAM_REGISTRY.map((program) => program.id))( + 'shows mint failures over every step and overlay in %s', + (program) => { + const router = new WizardRouter(program); + router.pushOverlay(Overlay.WizardAsk); + for (const code of [ + ErrorCodes.GatewayMintRefused, + ErrorCodes.GatewayMintFailed, + ]) { + const session = baseWizardSession(); + session.outroDismissed = true; + session.outroData = { kind: OutroKind.Error, errorCode: code }; + expect(router.resolve(session)).toBe(ScreenId.MintFailure); + } + }, + ); + describe('resolve', () => { it('returns the first incomplete visible screen for the wizard flow', () => { const router = new WizardRouter(Program.PostHogIntegration); diff --git a/src/ui/tui/exit-line.ts b/src/ui/tui/exit-line.ts index 8ff25442d..fee97acc6 100644 --- a/src/ui/tui/exit-line.ts +++ b/src/ui/tui/exit-line.ts @@ -15,6 +15,7 @@ import { totalTokenCount, type WizardStore } from './store.js'; import { OutroKind } from '@lib/wizard-session'; +import { isMintFailure, MINT_FAILURE_CONTACT } from '@ui/mint-failure'; import { formatTokenCount, formatCostUsd } from '@lib/agent/token-pricing'; const RESET_ATTRS = '\x1b[0m'; @@ -69,6 +70,16 @@ export function getExitLine(store: WizardStore): string { const costLine = tokenCostLine(store); const loginBlock = mcpLoginBlock(store); + if (isMintFailure(outro)) { + return [ + 'The wizard is unavailable. Setup has not been completed.', + outro?.handoffPrompt, + MINT_FAILURE_CONTACT, + ] + .filter(Boolean) + .join('\n\n'); + } + if (outro?.kind === OutroKind.Success) { const message = outro.message ?? `${label} completed successfully.`; const reportSuffix = diff --git a/src/ui/tui/ink-ui.ts b/src/ui/tui/ink-ui.ts index 60c9f27da..451af848a 100644 --- a/src/ui/tui/ink-ui.ts +++ b/src/ui/tui/ink-ui.ts @@ -17,6 +17,7 @@ import type { SettingsConflict } from '@lib/agent/claude-settings'; import type { WizardReadinessResult } from '@lib/health-checks/readiness'; import type { ApiUser } from '@lib/api'; import type { + AgentRunContext, AskAnswers, Credentials, OutroData, @@ -61,6 +62,7 @@ export class InkUI implements WizardUI { } outroError(data: OutroData): void { + this.store.setOutroDismissed(false); this.store.setOutroData(data); // Advance router past the run step so the outro screen renders if (this.store.session.runPhase !== RunPhase.Error) { @@ -114,6 +116,10 @@ export class InkUI implements WizardUI { return this.store.session.frameworkContext[key]; } + setAgentRunContext(context: AgentRunContext): void { + this.store.setAgentRunContext(context); + } + setDetectedFramework(label: string): void { this.store.setDetectedFramework(label); } diff --git a/src/ui/tui/playground/PlaygroundApp.tsx b/src/ui/tui/playground/PlaygroundApp.tsx index 3c06e130e..81f6d5354 100644 --- a/src/ui/tui/playground/PlaygroundApp.tsx +++ b/src/ui/tui/playground/PlaygroundApp.tsx @@ -25,6 +25,7 @@ import { EndScreensDemo } from './demos/EndScreensDemo.js'; import { AiOptInDemo } from './demos/AiOptInDemo.js'; import { AskModalDemo } from './demos/AskModalDemo.js'; import { ViewportGuardDemo } from './demos/ViewportGuardDemo.js'; +import { MintFailureDemo } from './demos/MintFailureDemo.js'; interface PlaygroundAppProps { store: WizardStore; @@ -34,6 +35,11 @@ export const PlaygroundApp = ({ store }: PlaygroundAppProps) => { const tabs = [ { id: 'layout', label: 'Layout', component: }, { id: 'input', label: 'Input', component: }, + { + id: 'mint-failure', + label: 'Mint failure', + component: , + }, { id: 'ask-modal', label: 'Ask modal', component: }, { id: 'progress', label: 'Progress', component: }, { id: 'logs', label: 'Logs', component: }, diff --git a/src/ui/tui/playground/demos/MintFailureDemo.tsx b/src/ui/tui/playground/demos/MintFailureDemo.tsx new file mode 100644 index 000000000..80bcdaecc --- /dev/null +++ b/src/ui/tui/playground/demos/MintFailureDemo.tsx @@ -0,0 +1,53 @@ +import { Box, Text } from 'ink'; +import { useMemo, useState, useSyncExternalStore } from 'react'; +import { OutroKind } from '@lib/wizard-session'; +import { useKeyBindings } from '@ui/tui/hooks/useKeyBindings'; +import { MintFailureScreen } from '@ui/tui/screens/MintFailureScreen'; +import { WizardStore } from '@ui/tui/store'; + +const leaveSpellbook = () => + Promise.resolve({ + path: '/example/project/POSTHOG_WIZARD_SPELLBOOK.md', + skillsIncluded: true, + }); + +export const MintFailureDemo = () => { + const [revision, setRevision] = useState(0); + const store = useMemo(() => { + const isolated = new WizardStore(); + isolated.setOutroData({ kind: OutroKind.Error }); + return isolated; + }, [revision]); + + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + useKeyBindings('mint-failure-demo', [ + { + match: 'r', + label: 'r', + action: 'replay preview', + handler: () => setRevision((current) => current + 1), + }, + ]); + + return ( + + + Preview only — no files are written. Press r to replay. + + {store.session.outroDismissed ? ( + + Preview complete. Press r to replay. + + ) : ( + + )} + + ); +}; diff --git a/src/ui/tui/router.ts b/src/ui/tui/router.ts index c9b887072..bfcf3a331 100644 --- a/src/ui/tui/router.ts +++ b/src/ui/tui/router.ts @@ -13,6 +13,7 @@ */ import { RunPhase, type WizardSession } from '@lib/wizard-session'; +import { isMintFailure } from '@ui/mint-failure'; import { Program, type ProgramId } from '@lib/programs/program-registry'; import { PROGRAM_SEQUENCES, @@ -60,6 +61,9 @@ export class WizardRouter { * returns the first incomplete screen. */ resolve(session: WizardSession): ScreenName { + // Fatal mint errors interrupt every program until wizardAbort exits. + if (isMintFailure(session.outroData)) return ScreenId.MintFailure; + if (this.overlays.length > 0) { return this.overlays[this.overlays.length - 1]; } diff --git a/src/ui/tui/screen-registry.tsx b/src/ui/tui/screen-registry.tsx index 08d21b3bc..da1e93d8e 100644 --- a/src/ui/tui/screen-registry.tsx +++ b/src/ui/tui/screen-registry.tsx @@ -47,6 +47,9 @@ import { McpSuggestedPromptsScreen } from './screens/McpSuggestedPromptsScreen.j import { SlackConnectScreen } from './screens/SlackConnectScreen.js'; import { KeepSkillsScreen } from './screens/KeepSkillsScreen.js'; import { OutroScreen } from './screens/OutroScreen.js'; +import { MintFailureScreen } from './screens/MintFailureScreen.js'; +import { writeWizardSpellbook } from '@lib/wizard-spellbook'; +import { getProgramConfig } from '@lib/programs/program-registry'; import { ExitScreen } from './screens/ExitScreen.js'; import { AuthErrorScreen } from './screens/AuthErrorScreen.js'; import { SessionTimeoutScreen } from './screens/SessionTimeoutScreen.js'; @@ -57,12 +60,21 @@ import { createMcpSuggestedPromptsServices } from './services/mcp-suggested-prom import type { McpSuggestedPromptsServices } from './services/mcp-suggested-prompts-services.js'; export interface ScreenServices { + leaveSpellbook: () => ReturnType; mcpInstaller: McpInstaller; mcpSuggestedPromptsServices: McpSuggestedPromptsServices; } export function createServices(store: WizardStore): ScreenServices { return { + leaveSpellbook: () => + writeWizardSpellbook( + store.session.agentRunContext ?? store.session, + getProgramConfig( + store.session.agentRunContext?.programId ?? + store.router.activeProgram, + ), + ), mcpInstaller: createMcpInstaller(), mcpSuggestedPromptsServices: createMcpSuggestedPromptsServices(store), }; @@ -127,6 +139,12 @@ export function createScreens( [ScreenId.SlackConnect]: , [ScreenId.KeepSkills]: , [ScreenId.Outro]: , + [ScreenId.MintFailure]: ( + + ), [ScreenId.Exit]: , // Standalone MCP flows diff --git a/src/ui/tui/screen-sequences.ts b/src/ui/tui/screen-sequences.ts index 6b6b36cfb..19836755c 100644 --- a/src/ui/tui/screen-sequences.ts +++ b/src/ui/tui/screen-sequences.ts @@ -45,6 +45,7 @@ export enum ScreenId { SlackConnect = 'slack-connect', KeepSkills = 'keep-skills', Outro = 'outro', + MintFailure = 'mint-failure', Exit = 'exit', McpAdd = 'mcp-add', McpRemove = 'mcp-remove', diff --git a/src/ui/tui/screens/MintFailureScreen.tsx b/src/ui/tui/screens/MintFailureScreen.tsx new file mode 100644 index 000000000..47a4e54a3 --- /dev/null +++ b/src/ui/tui/screens/MintFailureScreen.tsx @@ -0,0 +1,131 @@ +import { Box, Text, measureElement, type DOMElement } from 'ink'; +import { useLayoutEffect, useRef, useState } from 'react'; +import type { WizardStore } from '@ui/tui/store'; +import { ConfirmationInput } from '@ui/tui/primitives/index'; +import { useStdoutDimensions } from '@ui/tui/hooks/useStdoutDimensions'; +import { useDismissOnAnyKey } from '@ui/tui/hooks/useDismissOnAnyKey'; +import { MINT_FAILURE_MESSAGE, MINT_FAILURE_CONTACT } from '@ui/mint-failure'; +import type { writeWizardSpellbook } from '@lib/wizard-spellbook'; + +type Spellbook = Awaited>; + +type MintFailureScreenProps = { + store: WizardStore; + leaveSpellbook: () => Promise; +}; + +enum Phase { + Ask = 'ask', + Saving = 'saving', + Saved = 'saved', +} + +export function MintFailureScreen({ + store, + leaveSpellbook, +}: MintFailureScreenProps) { + useStdoutDimensions(); + const container = useRef(null); + const saving = useRef(false); + const [width, setWidth] = useState(0); + const [phase, setPhase] = useState(Phase.Ask); + const [spellbook, setSpellbook] = useState(null); + const [error, setError] = useState(false); + + useLayoutEffect(() => { + if (!container.current) return; + const measuredWidth = measureElement(container.current).width; + if (measuredWidth !== width) setWidth(measuredWidth); + }); + + const dismiss = () => store.setOutroDismissed(); + useDismissOnAnyKey(() => { + if (phase === Phase.Saved) dismiss(); + }); + const save = async () => { + if (saving.current) return; + saving.current = true; + setError(false); + setPhase(Phase.Saving); + try { + const result = await leaveSpellbook(); + const outro = store.session.outroData; + if (outro) { + store.setOutroData({ + ...outro, + handoffPrompt: `Read ${result.path} and complete the setup described in the Wizard's spell book.`, + }); + } + setSpellbook(result); + setPhase(Phase.Saved); + } catch { + setError(true); + setPhase(Phase.Ask); + } finally { + saving.current = false; + } + }; + + return ( + + + {phase === Phase.Saved && spellbook ? ( + <> + The Wizard left its spell book behind. + + + {spellbook.path} + + + + + {spellbook.skillsIncluded + ? 'Ask your agent to read it and complete the setup.' + : 'Skills could not be downloaded. The spell book includes instructions and links for your agent to continue.'} + + + + ) : ( + {MINT_FAILURE_MESSAGE} + )} + + {MINT_FAILURE_CONTACT} + + + {phase === Phase.Saving ? ( + Leaving the spell book... + ) : phase === Phase.Saved ? ( + Press any key to exit + ) : ( + <> + {error && ( + + Could not save the spell book. Check this folder is writable + and try again. + + )} + void save()} + onCancel={dismiss} + /> + + )} + + + + ); +} diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index 12cf174ab..dbc045655 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -22,6 +22,7 @@ import { type TokenUsageDelta, } from '@ui/wizard-ui'; import { + type AgentRunContext, type WizardSession, type OutroData, type DiscoveredFeature, @@ -533,6 +534,11 @@ export class WizardStore { this.emitChange(); } + setAgentRunContext(context: AgentRunContext): void { + this.$session.setKey('agentRunContext', context); + this.emitChange(); + } + setSkillId(skillId: string | null): void { this.$session.setKey('skillId', skillId); this.emitChange(); @@ -908,8 +914,8 @@ export class WizardStore { this.setRunPhase(RunPhase.Idle); } - setOutroDismissed(): void { - this.$session.setKey('outroDismissed', true); + setOutroDismissed(dismissed = true): void { + this.$session.setKey('outroDismissed', dismissed); this.emitChange(); } diff --git a/src/ui/wizard-ui.ts b/src/ui/wizard-ui.ts index 38ad29235..f3c459074 100644 --- a/src/ui/wizard-ui.ts +++ b/src/ui/wizard-ui.ts @@ -11,7 +11,11 @@ import type { SettingsConflict } from '@lib/agent/claude-settings'; import type { WizardReadinessResult } from '@lib/health-checks/readiness'; import type { ApiUser } from '@lib/api'; -import type { Credentials, TaskNotice } from '@lib/wizard-session'; +import type { + AgentRunContext, + Credentials, + TaskNotice, +} from '@lib/wizard-session'; import type { AskAnswers, OutroData, @@ -221,6 +225,7 @@ export interface WizardUI { // ── Display state ────────────────────────────────────────────────── /** Set the detected framework label (e.g., "Django with Wagtail CMS") */ setDetectedFramework(label: string): void; + setAgentRunContext(context: AgentRunContext): void; /** Register a callback to run when the TUI transitions onto the given screen. */ onEnterScreen(screen: string, fn: () => void): void;