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) <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,7 @@ import { runTelegramChannel } from './channels/telegram.js';
|
|||||||
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
||||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||||
import { brightSelect } from './lib/bright-select.js';
|
import { brightSelect } from './lib/bright-select.js';
|
||||||
import { offerClaudeAssist } from './lib/claude-assist.js';
|
import { offerClaudeOnFailure } from './lib/claude-handoff.js';
|
||||||
import {
|
import {
|
||||||
applyToEnv,
|
applyToEnv,
|
||||||
parseFlags,
|
parseFlags,
|
||||||
@@ -416,7 +416,7 @@ async function main(): Promise<void> {
|
|||||||
} else {
|
} else {
|
||||||
phEmit('first_chat_failed', { reason: ping });
|
phEmit('first_chat_failed', { reason: ping });
|
||||||
renderPingFailureNote(ping);
|
renderPingFailureNote(ping);
|
||||||
await offerClaudeAssist({
|
await offerClaudeOnFailure({
|
||||||
stepName: 'cli-agent',
|
stepName: 'cli-agent',
|
||||||
msg:
|
msg:
|
||||||
ping === 'socket_error'
|
ping === 'socket_error'
|
||||||
@@ -528,7 +528,7 @@ async function main(): Promise<void> {
|
|||||||
service_running: res.terminal?.fields.SERVICE === 'running',
|
service_running: res.terminal?.fields.SERVICE === 'running',
|
||||||
has_credentials: res.terminal?.fields.CREDENTIALS === 'configured',
|
has_credentials: res.terminal?.fields.CREDENTIALS === 'configured',
|
||||||
});
|
});
|
||||||
await offerClaudeAssist({
|
await offerClaudeOnFailure({
|
||||||
stepName: 'verify',
|
stepName: 'verify',
|
||||||
msg: summary || 'Verification completed with unresolved issues.',
|
msg: summary || 'Verification completed with unresolved issues.',
|
||||||
hint: `Terminal block: ${JSON.stringify(res.terminal?.fields ?? {})}`,
|
hint: `Terminal block: ${JSON.stringify(res.terminal?.fields ?? {})}`,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export interface AssistContext {
|
|||||||
* rather than us stuffing contents into the prompt. Keys are step names as
|
* rather than us stuffing contents into the prompt. Keys are step names as
|
||||||
* they appear in fail() calls; values are repo-relative paths.
|
* they appear in fail() calls; values are repo-relative paths.
|
||||||
*/
|
*/
|
||||||
const STEP_FILES: Record<string, string[]> = {
|
export const STEP_FILES: Record<string, string[]> = {
|
||||||
bootstrap: ['setup.sh', 'setup/install-node.sh', 'nanoclaw.sh'],
|
bootstrap: ['setup.sh', 'setup/install-node.sh', 'nanoclaw.sh'],
|
||||||
environment: ['setup/environment.ts'],
|
environment: ['setup/environment.ts'],
|
||||||
container: [
|
container: [
|
||||||
@@ -81,7 +81,7 @@ const STEP_FILES: Record<string, string[]> = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
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
|
* 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<boolean> {
|
export async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
|
||||||
if (!isClaudeInstalled()) {
|
if (!isClaudeInstalled()) {
|
||||||
const install = ensureAnswer(
|
const install = ensureAnswer(
|
||||||
await p.confirm({
|
await p.confirm({
|
||||||
|
|||||||
@@ -23,10 +23,19 @@
|
|||||||
* attempting to parse it as a real answer.
|
* attempting to parse it as a real answer.
|
||||||
*/
|
*/
|
||||||
import { execSync, spawn } from 'child_process';
|
import { execSync, spawn } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
import * as p from '@clack/prompts';
|
import * as p from '@clack/prompts';
|
||||||
import k from 'kleur';
|
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';
|
import { brandBody, note } from './theme.js';
|
||||||
|
|
||||||
export interface HandoffContext {
|
export interface HandoffContext {
|
||||||
@@ -194,3 +203,110 @@ function buildSystemPrompt(ctx: HandoffContext): string {
|
|||||||
|
|
||||||
return lines.join('\n');
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean>((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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import * as p from '@clack/prompts';
|
|||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
import * as setupLog from '../logs.js';
|
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 { emit as phEmit } from './diagnostics.js';
|
||||||
import { brandBody, fitToWidth, fmtDuration } from './theme.js';
|
import { brandBody, fitToWidth, fmtDuration } from './theme.js';
|
||||||
|
|
||||||
@@ -367,7 +367,7 @@ export async function fail(
|
|||||||
if (hint) p.log.message(k.dim(hint));
|
if (hint) p.log.message(k.dim(hint));
|
||||||
p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/'));
|
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
|
// 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
|
// at the step that failed instead of aborting. We re-exec via spawnSync
|
||||||
|
|||||||
@@ -123,6 +123,15 @@ export const CONFIG: Entry[] = [
|
|||||||
surface: 'flag',
|
surface: 'flag',
|
||||||
type: 'string',
|
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 ───────────────────────────────────────────────────
|
// ─── name derivation ───────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
import * as p from '@clack/prompts';
|
import * as p from '@clack/prompts';
|
||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
import { offerClaudeAssist } from './claude-assist.js';
|
import { offerClaudeOnFailure } from './claude-handoff.js';
|
||||||
import { emit as phEmit } from './diagnostics.js';
|
import { emit as phEmit } from './diagnostics.js';
|
||||||
import type { StepResult, SpinnerLabels } from './runner.js';
|
import type { StepResult, SpinnerLabels } from './runner.js';
|
||||||
import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } 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.
|
// offerClaudeAssist runs its own spinner and may propose a fix command.
|
||||||
// We don't attempt to restart the stalled build from here — if Claude
|
// We don't attempt to restart the stalled build from here — if Claude
|
||||||
// proposes a command the user accepts, they can retry setup afterwards.
|
// proposes a command the user accepts, they can retry setup afterwards.
|
||||||
await offerClaudeAssist({
|
await offerClaudeOnFailure({
|
||||||
stepName,
|
stepName,
|
||||||
msg: `The ${stepName} step has produced no output for 60 seconds.`,
|
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.',
|
hint: 'It may be hung on a slow network pull or a failing Dockerfile step.',
|
||||||
|
|||||||
Reference in New Issue
Block a user