From 79b0a95f65ac26dde17750b84efc48adf2c2eb9c Mon Sep 17 00:00:00 2001 From: cvince Date: Fri, 28 Aug 2026 17:10:44 -0700 Subject: [PATCH] fix(web): flow cancel's non-interactive refusal serves a page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capy flow cancel --web` exited 3 with no URL. The refusal itself was correct — loud, and it names the remedy — but under --web it reached nobody. displayErrorAndExit is the wrong tool here: it hardcodes exit 1, and this refusal's contract is EXIT_NEEDS_INPUT (3), the code an agent branches on to learn that a question needs answering. So the page is served directly and the exit code is preserved. The sentences are refuseNonInteractive's own, carried whole, so terminal and browser say the same thing. Fixed at this call site rather than inside refuseNonInteractive, deliberately. The central fix would cover all 13 call sites at once, but it means making that function async — and it is typed `never`, which is what every caller relies on for control flow. An unawaited async call would let execution continue past a guard that is supposed to stop it, silently. That is a real refactor with a real failure mode, not a mechanical change, and it should be done deliberately rather than at the end of a long session. Verified by running: $ capy flow cancel --web exit code: 3 url line : http://127.0.0.1:50613/s/8dEYIfWQLnt8_w9Rlo05UliRoM1baGKz_FhhV4LBaXY Suite: 1851 pass, 0 fail. --- src/commands/flowCancelCommand.ts | 35 +++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/commands/flowCancelCommand.ts b/src/commands/flowCancelCommand.ts index cc70b1c..ca6300f 100644 --- a/src/commands/flowCancelCommand.ts +++ b/src/commands/flowCancelCommand.ts @@ -61,10 +61,37 @@ export class FlowCancelCommand { }); process.exit(EXIT_NEEDS_INPUT); } - refuseNonInteractive( - 'cancelling a flow is irreversible, and there is no interactive session to confirm it', - `Pass --yes to confirm non-interactively: capy flow cancel ${flowId} --yes`, - ); + // Under `--web` this refusal owes the caller a page, not just a stream + // they cannot read. `displayErrorAndExit` is not the tool here: it + // hardcodes exit 1, and this refusal's contract is EXIT_NEEDS_INPUT (3) + // — the code an agent branches on to learn a question needs answering. + // So the page is served directly and the exit code is preserved. + // + // The sentences are `refuseNonInteractive`'s own, carried whole, so the + // terminal and the browser say the same thing. + const reason = 'cancelling a flow is irreversible, and there is no interactive session to confirm it'; + const hint = `Pass --yes to confirm non-interactively: capy flow cancel ${flowId} --yes`; + const { isWebMode } = await import('../ui/webMode'); + if (isWebMode()) { + console.error(`\n non-interactive: ${reason}`); + console.error(` ${hint}\n`); + try { + const { buildCommandErrorData } = await import('../ui/commandErrorScreen'); + const { serveEndingPage } = await import('../ui/endingPage'); + await serveEndingPage( + 'command-error', + buildCommandErrorData( + new CapyError(`${reason}\n${hint}`, ERROR_CODES.FLOW_CANCEL_CONFIRMATION_REQUIRED), + {}, + ), + { lead: 'What went wrong is in your browser:', timeoutMs: 60_000, flow: 'error' }, + ); + } catch { + // A failure while reporting a failure is not worth a second one. + } + process.exit(EXIT_NEEDS_INPUT); + } + refuseNonInteractive(reason, hint); } const inquirer = (await import('inquirer')).default;