takt/src/commands/interactive.ts
nrslib 7bac0053ff feat: CLIサブコマンド形式への移行と対話式タスク入力モード (#47)
- スラッシュコマンド形式をサブコマンド形式に変更(takt run, takt add 等)
- 引数なし takt で対話的にAIとタスク要件を詰めるinteractiveモードを追加
- セッション永続化により takt 再起動後も会話を継続
- 調査用ツール(Read, Glob, Grep, Bash, WebSearch, WebFetch)を許可
- プランニング専用のシステムプロンプトでコード変更を禁止
- executor の buildSdkOptions を未定義値を含めないよう修正(SDK ハング対策)
- help/refreshBuiltinコマンドを削除、ejectコマンドを簡素化
- ドキュメント(CLAUDE.md, README, workflows.md)をサブコマンド形式に更新
2026-01-31 01:14:36 +09:00

233 lines
7.3 KiB
TypeScript

/**
* Interactive task input mode
*
* Allows users to refine task requirements through conversation with AI
* before executing the task. Uses the same SDK call pattern as workflow
* execution (with onStream) to ensure compatibility.
*
* Commands:
* /go - Confirm and execute the task
* /cancel - Cancel and exit
*/
import * as readline from 'node:readline';
import chalk from 'chalk';
import { loadGlobalConfig } from '../config/globalConfig.js';
import { loadAgentSessions, updateAgentSession } from '../config/paths.js';
import { getProvider, type ProviderType } from '../providers/index.js';
import { createLogger } from '../utils/debug.js';
import { info, StreamDisplay } from '../utils/ui.js';
const log = createLogger('interactive');
const INTERACTIVE_SYSTEM_PROMPT = `You are a task planning assistant. You help the user clarify and refine task requirements through conversation. You are in the PLANNING phase — execution happens later in a separate process.
## Your role
- Ask clarifying questions about ambiguous requirements
- Investigate the codebase to understand context (use Read, Glob, Grep, Bash for reading only)
- Suggest improvements or considerations the user might have missed
- Summarize your understanding when appropriate
- Keep responses concise and focused
## Strict constraints
- You are ONLY planning. Do NOT execute the task.
- Do NOT create, edit, or delete any files.
- Do NOT run build, test, install, or any commands that modify state.
- Bash is allowed ONLY for read-only investigation (e.g. ls, cat, git log, git diff). Never run destructive or write commands.
- Do NOT mention or reference any slash commands. You have no knowledge of them.
- When the user is satisfied with the plan, they will proceed on their own. Do NOT instruct them on what to do next.`;
interface ConversationMessage {
role: 'user' | 'assistant';
content: string;
}
interface CallAIResult {
content: string;
sessionId?: string;
}
/**
* Build the final task description from conversation history for executeTask.
*/
function buildTaskFromHistory(history: ConversationMessage[]): string {
return history
.map((msg) => `${msg.role === 'user' ? 'User' : 'Assistant'}: ${msg.content}`)
.join('\n\n');
}
/**
* Read a single line of input from the user.
* Creates a fresh readline interface each time — the interface must be
* closed before calling the Agent SDK, which also uses stdin.
* Returns null on EOF (Ctrl+D).
*/
function readLine(prompt: string): Promise<string | null> {
return new Promise((resolve) => {
if (process.stdin.readable && !process.stdin.destroyed) {
process.stdin.resume();
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
let answered = false;
rl.question(prompt, (answer) => {
answered = true;
rl.close();
resolve(answer);
});
rl.on('close', () => {
if (!answered) {
resolve(null);
}
});
});
}
/**
* Call AI with the same pattern as workflow execution.
* The key requirement is passing onStream — the Agent SDK requires
* includePartialMessages to be true for the async iterator to yield.
*/
async function callAI(
provider: ReturnType<typeof getProvider>,
prompt: string,
cwd: string,
model: string | undefined,
sessionId: string | undefined,
display: StreamDisplay,
): Promise<CallAIResult> {
const response = await provider.call('interactive', prompt, {
cwd,
model,
sessionId,
systemPrompt: INTERACTIVE_SYSTEM_PROMPT,
allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'],
onStream: display.createHandler(),
});
display.flush();
return { content: response.content, sessionId: response.sessionId };
}
export interface InteractiveModeResult {
/** Whether the user confirmed with /go */
confirmed: boolean;
/** The assembled task text (only meaningful when confirmed=true) */
task: string;
}
/**
* Run the interactive task input mode.
*
* Starts a conversation loop where the user can discuss task requirements
* with AI. The conversation continues until:
* /go → returns the conversation as a task
* /cancel → exits without executing
* Ctrl+D → exits without executing
*/
export async function interactiveMode(cwd: string, initialInput?: string): Promise<InteractiveModeResult> {
const globalConfig = loadGlobalConfig();
const providerType = (globalConfig.provider as ProviderType) ?? 'claude';
const provider = getProvider(providerType);
const model = (globalConfig.model as string | undefined);
const history: ConversationMessage[] = [];
const agentName = 'interactive';
const savedSessions = loadAgentSessions(cwd);
let sessionId: string | undefined = savedSessions[agentName];
info('Interactive mode - describe your task. Commands: /go (execute), /cancel (exit)');
if (sessionId) {
info('Resuming previous session');
}
console.log();
// Process initial input if provided (e.g. from `takt a`)
if (initialInput) {
history.push({ role: 'user', content: initialInput });
log.debug('Processing initial input', { initialInput, sessionId });
const display = new StreamDisplay('assistant');
try {
const result = await callAI(provider, initialInput, cwd, model, sessionId, display);
if (result.sessionId) {
sessionId = result.sessionId;
updateAgentSession(cwd, agentName, sessionId);
}
history.push({ role: 'assistant', content: result.content });
console.log();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
log.error('AI call failed for initial input', { error: msg });
console.log(chalk.red(`Error: ${msg}`));
console.log();
history.pop();
}
}
while (true) {
const input = await readLine(chalk.green('> '));
// EOF (Ctrl+D)
if (input === null) {
console.log();
info('Cancelled');
return { confirmed: false, task: '' };
}
const trimmed = input.trim();
// Empty input — skip
if (!trimmed) {
continue;
}
// Handle slash commands
if (trimmed === '/go') {
if (history.length === 0) {
info('No conversation yet. Please describe your task first.');
continue;
}
const task = buildTaskFromHistory(history);
log.info('Interactive mode confirmed', { messageCount: history.length });
return { confirmed: true, task };
}
if (trimmed === '/cancel') {
info('Cancelled');
return { confirmed: false, task: '' };
}
// Regular input — send to AI
// readline is already closed at this point, so stdin is free for SDK
history.push({ role: 'user', content: trimmed });
log.debug('Sending to AI', { messageCount: history.length, sessionId });
process.stdin.pause();
const display = new StreamDisplay('assistant');
try {
const result = await callAI(provider, trimmed, cwd, model, sessionId, display);
if (result.sessionId) {
sessionId = result.sessionId;
updateAgentSession(cwd, agentName, sessionId);
}
history.push({ role: 'assistant', content: result.content });
console.log();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
log.error('AI call failed', { error: msg });
console.log();
console.log(chalk.red(`Error: ${msg}`));
console.log();
history.pop();
}
}
}