@@ -444,6 +444,18 @@ export async function spawnCoanaDlx(
444444 )
445445 }
446446
447+ // `shadowNpmBase` (the dlx launcher) configures the child's stdio from its
448+ // `options` arg, NOT from the registry-spawn `extra` arg — the latter only
449+ // attaches metadata to the result. Callers that requested streaming via
450+ // `spawnExtra` (the 4th arg), e.g. `{ stdio: 'inherit' }` from
451+ // `socket manifest gradle`, were therefore silently ignored on this path:
452+ // Coana ran piped and its output — including the real failure reason — never
453+ // reached the user, leaving only an unhelpful "command failed". Promote the
454+ // requested stdio into the dlx options so it is honored here too.
455+ // `spawnCoanaScriptViaNode` already reads `spawnExtra.stdio` for the
456+ // local-path and npm-install branches, so this aligns all three paths.
457+ const requestedStdio = spawnExtra ?. [ 'stdio' ] ?? getOwn ( dlxOptions , 'stdio' )
458+
447459 try {
448460 // Use npm/dlx version.
449461 const result = await spawnDlx (
@@ -454,8 +466,14 @@ export async function spawnCoanaDlx(
454466 args ,
455467 {
456468 force : true ,
457- silent : true ,
469+ // Do NOT silence the launcher. `--silent` (npm loglevel silent) hides
470+ // npm's own download/registry/launch errors, so when npx/pnpm-dlx fails
471+ // to fetch @coana -tech/cli the user is left with a bare exit code and no
472+ // cause. shadowNpmBase defaults to `--loglevel error`, which keeps real
473+ // launcher errors visible while staying quiet on success.
474+ silent : false ,
458475 ...dlxOptions ,
476+ ...( requestedStdio === undefined ? { } : { stdio : requestedStdio } ) ,
459477 env : finalEnv ,
460478 ipc : {
461479 [ constants . SOCKET_CLI_SHADOW_ACCEPT_RISKS ] : true ,
@@ -484,7 +502,7 @@ export async function spawnCoanaDlx(
484502 }
485503
486504 logger . warn (
487- 'Coana dlx invocation failed before Coana started; falling back to `npm install` + `node`.' ,
505+ 'Coana dlx invocation failed; retrying via `npm install` + `node`.' ,
488506 )
489507
490508 const fallbackResult = await spawnCoanaViaNpmInstall (
@@ -526,10 +544,29 @@ export async function spawnCoanaDlx(
526544 * rather than blindly re-running Coana.
527545 */
528546function shouldFallbackOnDlxError ( e : unknown ) : boolean {
529- const capturedStderr = String ( ( e as any ) ?. stderr ?? '' )
530- if ( capturedStderr && / C o a n a C L I v e r s i o n / i. test ( capturedStderr ) ) {
547+ // Coana clearly ran (its banner is in the captured stderr) → any later
548+ // non-zero exit is a real Coana failure and retrying would hit it again.
549+ if ( coanaBannerSeen ( e ) ) {
531550 return false
532551 }
552+ return dlxLauncherFailedBeforeCoana ( e )
553+ }
554+
555+ /**
556+ * Heuristic: did the dlx launcher (npx / pnpm dlx / yarn dlx) fail BEFORE the
557+ * Coana process itself started? True for spawn-level errors (a string `code`
558+ * like ENOENT), signal kills, and exit codes >= 128 (conventionally
559+ * signal-derived) — all cases where the launcher, not Coana, is the culprit
560+ * (e.g. npx missing from PATH, or @coana-tech/cli failing to download). A small
561+ * integer exit code is deliberately NOT treated as a launch failure: Coana's
562+ * own exit codes are small integers too, so it is genuinely ambiguous.
563+ *
564+ * Caveat: a launcher that fails to download the package can also exit with a
565+ * small integer (npm/npx often exit 1), which lands in the ambiguous bucket.
566+ * We cannot disambiguate those from a real Coana exit without inspecting the
567+ * launcher's output, so the npm-install fallback does not fire for them.
568+ */
569+ function dlxLauncherFailedBeforeCoana ( e : unknown ) : boolean {
533570 const code = ( e as any ) ?. code
534571 // Spawn-level failure (e.g. ENOENT when npx is missing from PATH).
535572 if ( typeof code === 'string' ) {
@@ -541,10 +578,18 @@ function shouldFallbackOnDlxError(e: unknown): boolean {
541578 }
542579 // Exit codes >= 128 are conventionally signal-derived, and the observed
543580 // npx-launcher failures in the wild fall into this range (e.g. 249, 254).
544- if ( typeof code === 'number' && code >= 128 ) {
545- return true
546- }
547- return false
581+ return typeof code === 'number' && code >= 128
582+ }
583+
584+ /**
585+ * Definitive proof Coana actually booted: its startup banner appears in the
586+ * captured stderr. Only available when the launcher's output was piped
587+ * (captured); with inherited stdio there is nothing to inspect, so this
588+ * returns false (the failure is then classified by exit code / signal alone).
589+ */
590+ function coanaBannerSeen ( e : unknown ) : boolean {
591+ const capturedStderr = String ( ( e as any ) ?. stderr ?? '' )
592+ return ! ! capturedStderr && / C o a n a C L I v e r s i o n / i. test ( capturedStderr )
548593}
549594
550595/**
@@ -553,6 +598,7 @@ function shouldFallbackOnDlxError(e: unknown): boolean {
553598 */
554599function buildDlxErrorResult ( e : unknown ) : CResult < string > {
555600 const stderr = ( e as any ) ?. stderr
601+ const stdout = ( e as any ) ?. stdout
556602 const exitCode = ( e as any ) ?. code
557603 const signal = ( e as any ) ?. signal
558604 const cause = getErrorCause ( e )
@@ -564,9 +610,29 @@ function buildDlxErrorResult(e: unknown): CResult<string> {
564610 details . push ( `signal ${ signal } ` )
565611 }
566612 const detailSuffix = details . length ? ` (${ details . join ( ', ' ) } )` : ''
567- const message = stderr
568- ? `Coana command failed${ detailSuffix } : ${ stderr } `
569- : `Coana command failed${ detailSuffix } : ${ cause } `
613+ // Prefer captured stderr, then stdout, then the generic spawn error. Coana
614+ // logs some failures (e.g. unresolved Gradle dependencies) to stdout, so
615+ // without the stdout fallback a piped failure collapsed to an unhelpful
616+ // "command failed" even when the real reason was captured.
617+ const detail = stderr || stdout || cause
618+ // Be honest about WHERE the failure happened. On the dlx path the spawned
619+ // process is the package-manager launcher (npx / pnpm dlx / yarn dlx), which
620+ // downloads @coana -tech/cli and only then runs it — so a failure may be the
621+ // launcher dying before Coana ever started, not Coana itself. We can only be
622+ // CERTAIN of that for a spawn-level error (a string `code` like ENOENT: the
623+ // launcher binary could not start, so Coana provably never ran). A non-zero
624+ // exit or signal is genuinely ambiguous — Coana may have started, streamed
625+ // output, and then died (e.g. OOM), or the launcher may have failed to fetch
626+ // the package — and with inherited stdio there is no captured output to tell
627+ // them apart, so we must not assert either way.
628+ let message : string
629+ if ( coanaBannerSeen ( e ) ) {
630+ message = `Coana command failed${ detailSuffix } : ${ detail } `
631+ } else if ( typeof ( e as any ) ?. code === 'string' ) {
632+ message = `Failed to launch Coana via the package manager${ detailSuffix } — the npx/pnpm-dlx/yarn-dlx launcher could not start (e.g. it is missing from PATH): ${ detail } `
633+ } else {
634+ message = `Coana failed to run via the package manager${ detailSuffix } : ${ detail } `
635+ }
570636 return {
571637 ok : false ,
572638 data : e ,
0 commit comments