From 9a649fadc5ad60a191ef10ed603121826d418c7a Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 15:33:02 +0300 Subject: [PATCH] feat(setup): default to interactive Claude handoff on failure Failures now launch an interactive Claude session instead of the non-interactive assist (REASON/COMMAND parser). The user debugs with full terminal access and types /exit to return to setup. The original assist mode is available via --assist-mode flag or NANOCLAW_SETUP_ASSIST_MODE=1 env var. Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/auto.ts | 6 +- setup/lib/claude-assist.ts | 6 +- setup/lib/claude-handoff.ts | 116 +++++++++++++++++++++++++++++++++++ setup/lib/runner.ts | 4 +- setup/lib/setup-config.ts | 9 +++ setup/lib/windowed-runner.ts | 4 +- 6 files changed, 135 insertions(+), 10 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index bfe1ab4..5428d03 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -39,7 +39,7 @@ import { runTelegramChannel } from './channels/telegram.js'; import { runWhatsAppChannel } from './channels/whatsapp.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { brightSelect } from './lib/bright-select.js'; -import { offerClaudeAssist } from './lib/claude-assist.js'; +import { offerClaudeOnFailure } from './lib/claude-handoff.js'; import { applyToEnv, parseFlags, @@ -416,7 +416,7 @@ async function main(): Promise { } else { phEmit('first_chat_failed', { reason: ping }); renderPingFailureNote(ping); - await offerClaudeAssist({ + await offerClaudeOnFailure({ stepName: 'cli-agent', msg: ping === 'socket_error' @@ -528,7 +528,7 @@ async function main(): Promise { service_running: res.terminal?.fields.SERVICE === 'running', has_credentials: res.terminal?.fields.CREDENTIALS === 'configured', }); - await offerClaudeAssist({ + await offerClaudeOnFailure({ stepName: 'verify', msg: summary || 'Verification completed with unresolved issues.', hint: `Terminal block: ${JSON.stringify(res.terminal?.fields ?? {})}`, diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 187377e..8c0910d 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -43,7 +43,7 @@ export interface AssistContext { * rather than us stuffing contents into the prompt. Keys are step names as * they appear in fail() calls; values are repo-relative paths. */ -const STEP_FILES: Record = { +export const STEP_FILES: Record = { bootstrap: ['setup.sh', 'setup/install-node.sh', 'nanoclaw.sh'], environment: ['setup/environment.ts'], container: [ @@ -81,7 +81,7 @@ const STEP_FILES: Record = { ], }; -const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts']; +export const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts']; /** * Returns `true` if the user ran a Claude-suggested fix command; callers @@ -150,7 +150,7 @@ function isClaudeAuthenticated(): boolean { } } -async function ensureClaudeReady(projectRoot: string): Promise { +export async function ensureClaudeReady(projectRoot: string): Promise { if (!isClaudeInstalled()) { const install = ensureAnswer( await p.confirm({ diff --git a/setup/lib/claude-handoff.ts b/setup/lib/claude-handoff.ts index 87023ef..892b397 100644 --- a/setup/lib/claude-handoff.ts +++ b/setup/lib/claude-handoff.ts @@ -23,10 +23,19 @@ * attempting to parse it as a real answer. */ import { execSync, spawn } from 'child_process'; +import path from 'path'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { + type AssistContext, + BIG_PICTURE_FILES, + ensureClaudeReady, + offerClaudeAssist, + STEP_FILES, +} from './claude-assist.js'; +import { ensureAnswer } from './runner.js'; import { brandBody, note } from './theme.js'; export interface HandoffContext { @@ -194,3 +203,110 @@ function buildSystemPrompt(ctx: HandoffContext): string { return lines.join('\n'); } + +/** + * Dispatcher: checks NANOCLAW_SETUP_ASSIST_MODE and delegates to either + * the interactive failure handoff (default) or the non-interactive assist. + * + * Drop-in replacement for `offerClaudeAssist` at failure call sites. + */ +export async function offerClaudeOnFailure( + ctx: AssistContext, + projectRoot: string = process.cwd(), +): Promise { + if (process.env.NANOCLAW_SETUP_ASSIST_MODE === 'true' || process.env.NANOCLAW_SETUP_ASSIST_MODE === '1') { + return offerClaudeAssist(ctx, projectRoot); + } + return offerFailureHandoff(ctx, projectRoot); +} + +/** + * Interactive Claude handoff for setup failures. Same role as + * `offerClaudeAssist` but spawns an interactive session instead of + * parsing a structured REASON/COMMAND response. + * + * Returns `true` if Claude was launched (the user may have fixed + * things during the session), `false` if skipped/declined/unavailable. + */ +async function offerFailureHandoff( + ctx: AssistContext, + projectRoot: string, +): Promise { + if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false; + if (!(await ensureClaudeReady(projectRoot))) return false; + + const want = ensureAnswer( + await p.confirm({ + message: 'Want to debug this with Claude?', + initialValue: true, + }), + ); + if (!want) return false; + + const systemPrompt = buildFailureSystemPrompt(ctx, projectRoot); + + note( + [ + "Launching Claude to help debug this failure.", + "It has the context of what went wrong.", + "", + k.dim("Type /exit (or press Ctrl-D) when you're ready to come back to setup."), + ].join('\n'), + 'Handing off to Claude', + ); + + return new Promise((resolve) => { + const child = spawn( + 'claude', + [ + '--append-system-prompt', + systemPrompt, + '--permission-mode', + 'acceptEdits', + ], + { stdio: 'inherit' }, + ); + child.on('close', () => { + p.log.success(brandBody("Back from Claude. Let's continue.")); + resolve(true); + }); + child.on('error', () => { + p.log.error("Couldn't launch Claude. Continuing without handoff."); + resolve(false); + }); + }); +} + +function buildFailureSystemPrompt(ctx: AssistContext, projectRoot: string): string { + const stepRefs = STEP_FILES[ctx.stepName] ?? []; + const references = [ + ...BIG_PICTURE_FILES, + ...stepRefs, + 'logs/setup.log', + ctx.rawLogPath + ? path.relative(projectRoot, ctx.rawLogPath) + : 'logs/setup-steps/', + ].filter((v, i, a) => a.indexOf(v) === i); + + const lines: string[] = [ + "The user is running NanoClaw's interactive setup flow and hit a failure.", + '', + `Failed step: ${ctx.stepName}`, + `Error: ${ctx.msg}`, + ]; + + if (ctx.hint) lines.push(`Hint: ${ctx.hint}`); + + lines.push( + '', + 'Your job: help them diagnose and fix this issue. Read the referenced files', + 'and logs to understand what went wrong, then help them fix it. You can read', + 'files, run commands, check logs, and explain what happened. Be concise.', + "When they're ready to resume setup, tell them to type /exit.", + '', + 'Relevant files (read as needed with the Read tool):', + ); + for (const f of references) lines.push(` - ${f}`); + + return lines.join('\n'); +} diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts index 6ffffed..6adb02e 100644 --- a/setup/lib/runner.ts +++ b/setup/lib/runner.ts @@ -18,7 +18,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; -import { offerClaudeAssist } from './claude-assist.js'; +import { offerClaudeOnFailure } from './claude-handoff.js'; import { emit as phEmit } from './diagnostics.js'; import { brandBody, fitToWidth, fmtDuration } from './theme.js'; @@ -367,7 +367,7 @@ export async function fail( if (hint) p.log.message(k.dim(hint)); p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/')); - const ranFix = await offerClaudeAssist({ stepName, msg, hint, rawLogPath }); + const ranFix = await offerClaudeOnFailure({ stepName, msg, hint, rawLogPath }); // If the user just ran a Claude-suggested fix, offer to resume the flow // at the step that failed instead of aborting. We re-exec via spawnSync diff --git a/setup/lib/setup-config.ts b/setup/lib/setup-config.ts index 1fa6ad4..b8eb654 100644 --- a/setup/lib/setup-config.ts +++ b/setup/lib/setup-config.ts @@ -123,6 +123,15 @@ export const CONFIG: Entry[] = [ surface: 'flag', type: 'string', }, + { + key: 'assistMode', + envVar: 'NANOCLAW_SETUP_ASSIST_MODE', + label: 'Assist mode', + help: 'Use non-interactive Claude assist on failure instead of interactive handoff.', + surface: 'flag', + type: 'boolean', + default: false, + }, ]; // ─── name derivation ─────────────────────────────────────────────────── diff --git a/setup/lib/windowed-runner.ts b/setup/lib/windowed-runner.ts index 87c971e..f13dcd3 100644 --- a/setup/lib/windowed-runner.ts +++ b/setup/lib/windowed-runner.ts @@ -18,7 +18,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; -import { offerClaudeAssist } from './claude-assist.js'; +import { offerClaudeOnFailure } from './claude-handoff.js'; import { emit as phEmit } from './diagnostics.js'; import type { StepResult, SpinnerLabels } from './runner.js'; import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js'; @@ -212,7 +212,7 @@ async function handleStall( // offerClaudeAssist runs its own spinner and may propose a fix command. // We don't attempt to restart the stalled build from here — if Claude // proposes a command the user accepts, they can retry setup afterwards. - await offerClaudeAssist({ + await offerClaudeOnFailure({ stepName, msg: `The ${stepName} step has produced no output for 60 seconds.`, hint: 'It may be hung on a slow network pull or a failing Dockerfile step.',