From df22e4c33b5242169d6ef939a4634a6ec58e0f5e Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:43:39 +0900 Subject: [PATCH] =?UTF-8?q?add-task=E6=99=82=E3=81=AB=E6=94=B9=E8=A1=8C?= =?UTF-8?q?=E3=82=92=E8=A8=B1=E5=8F=AF=E3=80=82worktree=E3=81=A7=E5=A4=B1?= =?UTF-8?q?=E6=95=97=E3=81=99=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/prompt.test.ts | 42 +++++++++++++++++++++++++ src/claude/client.ts | 18 +++++++++++ src/commands/addTask.ts | 11 ++++--- src/commands/workflowExecution.ts | 19 +++++++++--- src/models/types.ts | 2 ++ src/prompt/index.ts | 51 +++++++++++++++++++++++++++++++ src/utils/session.ts | 2 ++ 7 files changed, 136 insertions(+), 9 deletions(-) diff --git a/src/__tests__/prompt.test.ts b/src/__tests__/prompt.test.ts index 1fc65cf..67fd065 100644 --- a/src/__tests__/prompt.test.ts +++ b/src/__tests__/prompt.test.ts @@ -3,12 +3,14 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Readable } from 'node:stream'; import chalk from 'chalk'; import type { SelectOptionItem, KeyInputResult } from '../prompt/index.js'; import { renderMenu, countRenderedLines, handleKeyInput, + readMultilineFromStream, } from '../prompt/index.js'; import { isFullWidth, getDisplayWidth, truncateText } from '../utils/text.js'; @@ -424,6 +426,46 @@ describe('prompt', () => { }); }); + describe('readMultilineFromStream', () => { + it('should return null when first line is empty (cancel)', async () => { + const input = Readable.from(['\n']); + const result = await readMultilineFromStream(input); + expect(result).toBeNull(); + }); + + it('should return single line when followed by empty line', async () => { + const input = Readable.from(['hello world\n\n']); + const result = await readMultilineFromStream(input); + expect(result).toBe('hello world'); + }); + + it('should return multiple lines joined by newline', async () => { + const input = Readable.from(['line 1\nline 2\nline 3\n\n']); + const result = await readMultilineFromStream(input); + expect(result).toBe('line 1\nline 2\nline 3'); + }); + + it('should trim leading and trailing whitespace from the joined result', async () => { + const input = Readable.from([' hello \n world \n\n']); + const result = await readMultilineFromStream(input); + // .trim() is applied to the joined string, so leading spaces on first line are trimmed + expect(result).toBe('hello \n world'); + }); + + it('should handle stream close without empty line (Ctrl+C)', async () => { + // Stream ends without empty line terminator + const input = Readable.from(['some content\n']); + const result = await readMultilineFromStream(input); + expect(result).toBe('some content'); + }); + + it('should return null when stream closes with no input', async () => { + const input = Readable.from([]); + const result = await readMultilineFromStream(input); + expect(result).toBeNull(); + }); + }); + describe('selectOptionWithDefault cancel behavior', () => { it('handleKeyInput should return cancel with optionCount when hasCancelOption is true', () => { // Simulates ESC key press with cancel option enabled (as selectOptionWithDefault now does) diff --git a/src/claude/client.ts b/src/claude/client.ts index 347b173..c6f9de7 100644 --- a/src/claude/client.ts +++ b/src/claude/client.ts @@ -8,6 +8,9 @@ import { executeClaudeCli, type ClaudeSpawnOptions, type StreamCallback, type Pe import type { AgentDefinition } from '@anthropic-ai/claude-agent-sdk'; import type { AgentResponse, Status, PermissionMode } from '../models/types.js'; import { GENERIC_STATUS_PATTERNS } from '../models/schemas.js'; +import { createLogger } from '../utils/debug.js'; + +const log = createLogger('client'); /** Options for calling Claude */ export interface ClaudeCallOptions { @@ -125,12 +128,17 @@ export async function callClaude( const patterns = options.statusPatterns || getBuiltinStatusPatterns(agentType); const status = determineStatus(result, patterns); + if (!result.success && result.error) { + log.error('Agent query failed', { agent: agentType, error: result.error }); + } + return { agent: agentType, status, content: result.content, timestamp: new Date(), sessionId: result.sessionId, + error: result.error, }; } @@ -160,12 +168,17 @@ export async function callClaudeCustom( const patterns = options.statusPatterns || getBuiltinStatusPatterns(agentName); const status = determineStatus(result, patterns); + if (!result.success && result.error) { + log.error('Agent query failed', { agent: agentName, error: result.error }); + } + return { agent: agentName, status, content: result.content, timestamp: new Date(), sessionId: result.sessionId, + error: result.error, }; } @@ -206,11 +219,16 @@ export async function callClaudeSkill( const result = await executeClaudeCli(fullPrompt, spawnOptions); + if (!result.success && result.error) { + log.error('Skill query failed', { skill: skillName, error: result.error }); + } + return { agent: `skill:${skillName}`, status: result.success ? 'done' : 'blocked', content: result.content, timestamp: new Date(), sessionId: result.sessionId, + error: result.error, }; } diff --git a/src/commands/addTask.ts b/src/commands/addTask.ts index cf4ea1e..687f631 100644 --- a/src/commands/addTask.ts +++ b/src/commands/addTask.ts @@ -8,7 +8,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { stringify as stringifyYaml } from 'yaml'; -import { promptInput, confirm, selectOption } from '../prompt/index.js'; +import { promptInput, promptMultilineInput, confirm, selectOption } from '../prompt/index.js'; import { success, info } from '../utils/ui.js'; import { slugify } from '../utils/slug.js'; import { createLogger } from '../utils/debug.js'; @@ -55,8 +55,8 @@ export async function addTask(cwd: string, args: string[]): Promise { // Argument mode: task content provided directly taskContent = args.join(' '); } else { - // Interactive mode - const input = await promptInput('Task content'); + // Interactive mode (multiline: empty line to finish) + const input = await promptMultilineInput('Task content'); if (!input) { info('Cancelled.'); return; @@ -110,8 +110,9 @@ export async function addTask(cwd: string, args: string[]): Promise { taskData.workflow = workflow; } - // Write YAML filea - const filename = generateFilename(tasksDir, taskContent); + // Write YAML file (use first line for filename to keep it short) + const firstLine = taskContent.split('\n')[0] || taskContent; + const filename = generateFilename(tasksDir, firstLine); const filePath = path.join(tasksDir, filename); const yamlContent = stringifyYaml(taskData); fs.writeFileSync(filePath, yamlContent, 'utf-8'); diff --git a/src/commands/workflowExecution.ts b/src/commands/workflowExecution.ts index 3f16a7d..a8cffe0 100644 --- a/src/commands/workflowExecution.ts +++ b/src/commands/workflowExecution.ts @@ -97,12 +97,19 @@ export async function executeWorkflow( }; // Load saved agent sessions for continuity (from project root) - const savedSessions = loadAgentSessions(projectCwd); + // When running in a worktree (cwd !== projectCwd), skip session resume because + // Claude Code sessions are stored per-cwd in ~/.claude/projects/{encoded-path}/ + // and sessions from the main project dir can't be resumed in a worktree dir. + const isWorktree = cwd !== projectCwd; + const savedSessions = isWorktree ? {} : loadAgentSessions(projectCwd); // Session update handler - persist session IDs when they change (to project root) - const sessionUpdateHandler = (agentName: string, agentSessionId: string): void => { - updateAgentSession(projectCwd, agentName, agentSessionId); - }; + // Skip persisting worktree sessions since they can't be reused across different worktrees. + const sessionUpdateHandler = isWorktree + ? undefined + : (agentName: string, agentSessionId: string): void => { + updateAgentSession(projectCwd, agentName, agentSessionId); + }; const iterationLimitHandler = async ( request: IterationLimitRequest @@ -169,6 +176,7 @@ export async function executeWorkflow( status: response.status, contentLength: response.content.length, sessionId: response.sessionId, + error: response.error, }); if (displayRef.current) { displayRef.current.flush(); @@ -176,6 +184,9 @@ export async function executeWorkflow( } console.log(); status('Status', response.status); + if (response.error) { + error(`Error: ${response.error}`); + } if (response.sessionId) { status('Session', response.sessionId); } diff --git a/src/models/types.ts b/src/models/types.ts index 5e12e95..ec22dda 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -35,6 +35,8 @@ export interface AgentResponse { content: string; timestamp: Date; sessionId?: string; + /** Error message when the query failed (e.g., API error, rate limit) */ + error?: string; } /** Session state for workflow execution */ diff --git a/src/prompt/index.ts b/src/prompt/index.ts index 5b487ec..7e8b86f 100644 --- a/src/prompt/index.ts +++ b/src/prompt/index.ts @@ -296,6 +296,57 @@ export async function promptInput(message: string): Promise { }); } +/** + * Read multiline input from a readable stream. + * An empty line finishes input. If the first line is empty, returns null. + * Exported for testing. + */ +export function readMultilineFromStream(input: NodeJS.ReadableStream): Promise { + const lines: string[] = []; + const rl = readline.createInterface({ input }); + + return new Promise((resolve) => { + let resolved = false; + + rl.on('line', (line) => { + if (line === '' && lines.length > 0) { + resolved = true; + rl.close(); + const result = lines.join('\n').trim(); + resolve(result || null); + return; + } + + if (line === '' && lines.length === 0) { + resolved = true; + rl.close(); + resolve(null); + return; + } + + lines.push(line); + }); + + rl.on('close', () => { + if (!resolved) { + resolve(lines.length > 0 ? lines.join('\n').trim() : null); + } + }); + }); +} + +/** + * Prompt user for multiline text input. + * Each line is entered with Enter. An empty line finishes input. + * If the first line is empty, returns null (cancel). + * @returns Multiline text or null if cancelled + */ +export async function promptMultilineInput(message: string): Promise { + console.log(chalk.green(`${message} (empty line to finish):`)); + process.stdout.write(chalk.gray('> ')); + return readMultilineFromStream(process.stdin); +} + /** * Prompt user to select from a list of options with a default value. * Uses cursor navigation. Enter immediately selects the default. diff --git a/src/utils/session.ts b/src/utils/session.ts index d776539..7341234 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -22,6 +22,7 @@ export interface SessionLog { status: string; timestamp: string; content: string; + error?: string; }>; } @@ -83,6 +84,7 @@ export function addToSessionLog( status: response.status, timestamp: response.timestamp.toISOString(), content: response.content, + ...(response.error ? { error: response.error } : {}), }); log.iterations++; }