mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37:45 +08:00
feat(cli-executor): add streaming option and enhance output handling
- Introduced a `stream` parameter to control output streaming vs. caching. - Enhanced status determination logic to prioritize valid output over exit codes. - Updated output structure to include full stdout and stderr when not streaming. feat(cli-history-store): extend conversation turn schema and migration - Added `cached`, `stdout_full`, and `stderr_full` fields to the conversation turn schema. - Implemented database migration to add new columns if they do not exist. - Updated upsert logic to handle new fields. feat(codex-lens): implement global symbol index for fast lookups - Created `GlobalSymbolIndex` class to manage project-wide symbol indexing. - Added methods for adding, updating, and deleting symbols in the global index. - Integrated global index updates into directory indexing processes. feat(codex-lens): optimize search functionality with global index - Enhanced `ChainSearchEngine` to utilize the global symbol index for faster searches. - Added configuration option to enable/disable global symbol indexing. - Updated tests to validate global index functionality and performance.
This commit is contained in:
@@ -174,7 +174,7 @@ export function run(argv: string[]): void {
|
||||
.option('--cd <path>', 'Working directory')
|
||||
.option('--includeDirs <dirs>', 'Additional directories (--include-directories for gemini/qwen, --add-dir for codex/claude)')
|
||||
.option('--timeout <ms>', 'Timeout in milliseconds', '300000')
|
||||
.option('--no-stream', 'Disable streaming output')
|
||||
.option('--stream', 'Enable streaming output (default: non-streaming with caching)')
|
||||
.option('--limit <n>', 'History limit')
|
||||
.option('--status <status>', 'Filter by status')
|
||||
.option('--category <category>', 'Execution category: user, internal, insight', 'user')
|
||||
@@ -190,6 +190,11 @@ export function run(argv: string[]): void {
|
||||
.option('--memory', 'Target memory storage')
|
||||
.option('--storage-cache', 'Target cache storage')
|
||||
.option('--config', 'Target config storage')
|
||||
// Cache subcommand options
|
||||
.option('--offset <n>', 'Character offset for cache pagination', '0')
|
||||
.option('--output-type <type>', 'Output type: stdout, stderr, both', 'both')
|
||||
.option('--turn <n>', 'Turn number for cache (default: latest)')
|
||||
.option('--raw', 'Raw output only (no formatting)')
|
||||
.action((subcommand, args, options) => cliCommand(subcommand, args, options));
|
||||
|
||||
// Memory command
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
projectExists,
|
||||
getStorageLocationInstructions
|
||||
} from '../tools/storage-manager.js';
|
||||
import { getHistoryStore } from '../tools/cli-history-store.js';
|
||||
|
||||
// Dashboard notification settings
|
||||
const DASHBOARD_PORT = process.env.CCW_PORT || 3456;
|
||||
@@ -74,7 +75,7 @@ interface CliExecOptions {
|
||||
cd?: string;
|
||||
includeDirs?: string;
|
||||
timeout?: string;
|
||||
noStream?: boolean;
|
||||
stream?: boolean; // Enable streaming (default: false, caches output)
|
||||
resume?: string | boolean; // true = last, string = execution ID, comma-separated for merge
|
||||
id?: string; // Custom execution ID (e.g., IMPL-001-step1)
|
||||
noNative?: boolean; // Force prompt concatenation instead of native resume
|
||||
@@ -104,6 +105,14 @@ interface StorageOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
interface OutputViewOptions {
|
||||
offset?: string;
|
||||
limit?: string;
|
||||
outputType?: 'stdout' | 'stderr' | 'both';
|
||||
turn?: string;
|
||||
raw?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show storage information and management options
|
||||
*/
|
||||
@@ -287,6 +296,71 @@ function showStorageHelp(): void {
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show cached output for a conversation with pagination
|
||||
*/
|
||||
async function outputAction(conversationId: string | undefined, options: OutputViewOptions): Promise<void> {
|
||||
if (!conversationId) {
|
||||
console.error(chalk.red('Error: Conversation ID is required'));
|
||||
console.error(chalk.gray('Usage: ccw cli output <conversation-id> [--offset N] [--limit N]'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = getHistoryStore(process.cwd());
|
||||
const result = store.getCachedOutput(
|
||||
conversationId,
|
||||
options.turn ? parseInt(options.turn) : undefined,
|
||||
{
|
||||
offset: parseInt(options.offset || '0'),
|
||||
limit: parseInt(options.limit || '10000'),
|
||||
outputType: options.outputType || 'both'
|
||||
}
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
console.error(chalk.red(`Error: Execution not found: ${conversationId}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options.raw) {
|
||||
// Raw output only (for piping)
|
||||
if (result.stdout) console.log(result.stdout.content);
|
||||
return;
|
||||
}
|
||||
|
||||
// Formatted output
|
||||
console.log(chalk.bold.cyan('Execution Output\n'));
|
||||
console.log(` ${chalk.gray('ID:')} ${result.conversationId}`);
|
||||
console.log(` ${chalk.gray('Turn:')} ${result.turnNumber}`);
|
||||
console.log(` ${chalk.gray('Cached:')} ${result.cached ? chalk.green('Yes') : chalk.yellow('No')}`);
|
||||
console.log(` ${chalk.gray('Status:')} ${result.status}`);
|
||||
console.log(` ${chalk.gray('Time:')} ${result.timestamp}`);
|
||||
console.log();
|
||||
|
||||
if (result.stdout) {
|
||||
console.log(` ${chalk.gray('Stdout:')} (${result.stdout.totalBytes} bytes, offset ${result.stdout.offset})`);
|
||||
console.log(chalk.gray(' ' + '-'.repeat(60)));
|
||||
console.log(result.stdout.content);
|
||||
console.log(chalk.gray(' ' + '-'.repeat(60)));
|
||||
if (result.stdout.hasMore) {
|
||||
console.log(chalk.yellow(` ... ${result.stdout.totalBytes - result.stdout.offset - result.stdout.content.length} more bytes available`));
|
||||
console.log(chalk.gray(` Use --offset ${result.stdout.offset + result.stdout.content.length} to continue`));
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (result.stderr && result.stderr.content) {
|
||||
console.log(` ${chalk.gray('Stderr:')} (${result.stderr.totalBytes} bytes, offset ${result.stderr.offset})`);
|
||||
console.log(chalk.gray(' ' + '-'.repeat(60)));
|
||||
console.log(result.stderr.content);
|
||||
console.log(chalk.gray(' ' + '-'.repeat(60)));
|
||||
if (result.stderr.hasMore) {
|
||||
console.log(chalk.yellow(` ... ${result.stderr.totalBytes - result.stderr.offset - result.stderr.content.length} more bytes available`));
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test endpoint for debugging multi-line prompt parsing
|
||||
* Shows exactly how Commander.js parsed the arguments
|
||||
@@ -391,7 +465,7 @@ async function statusAction(): Promise<void> {
|
||||
* @param {Object} options - CLI options
|
||||
*/
|
||||
async function execAction(positionalPrompt: string | undefined, options: CliExecOptions): Promise<void> {
|
||||
const { prompt: optionPrompt, file, tool = 'gemini', mode = 'analysis', model, cd, includeDirs, timeout, noStream, resume, id, noNative, cache, injectMode } = options;
|
||||
const { prompt: optionPrompt, file, tool = 'gemini', mode = 'analysis', model, cd, includeDirs, timeout, stream, resume, id, noNative, cache, injectMode } = options;
|
||||
|
||||
// Priority: 1. --file, 2. --prompt/-p option, 3. positional argument
|
||||
let finalPrompt: string | undefined;
|
||||
@@ -584,10 +658,10 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
||||
custom_id: id || null
|
||||
});
|
||||
|
||||
// Streaming output handler
|
||||
const onOutput = noStream ? null : (chunk: any) => {
|
||||
// Streaming output handler - only active when --stream flag is passed
|
||||
const onOutput = stream ? (chunk: any) => {
|
||||
process.stdout.write(chunk.data);
|
||||
};
|
||||
} : null;
|
||||
|
||||
try {
|
||||
const result = await cliExecutorTool.execute({
|
||||
@@ -600,11 +674,12 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
||||
timeout: timeout ? parseInt(timeout, 10) : 300000,
|
||||
resume,
|
||||
id, // custom execution ID
|
||||
noNative
|
||||
noNative,
|
||||
stream: !!stream // stream=true → streaming enabled, stream=false/undefined → cache output
|
||||
}, onOutput);
|
||||
|
||||
// If not streaming, print output now
|
||||
if (noStream && result.stdout) {
|
||||
// If not streaming (default), print output now
|
||||
if (!stream && result.stdout) {
|
||||
console.log(result.stdout);
|
||||
}
|
||||
|
||||
@@ -815,6 +890,10 @@ export async function cliCommand(
|
||||
await storageAction(argsArray[0], options as unknown as StorageOptions);
|
||||
break;
|
||||
|
||||
case 'output':
|
||||
await outputAction(argsArray[0], options as unknown as OutputViewOptions);
|
||||
break;
|
||||
|
||||
case 'test-parse':
|
||||
// Test endpoint to debug multi-line prompt parsing
|
||||
testParseAction(argsArray, options as CliExecOptions);
|
||||
@@ -845,6 +924,7 @@ export async function cliCommand(
|
||||
console.log(chalk.gray(' storage [cmd] Manage CCW storage (info/clean/config)'));
|
||||
console.log(chalk.gray(' history Show execution history'));
|
||||
console.log(chalk.gray(' detail <id> Show execution detail'));
|
||||
console.log(chalk.gray(' output <id> Show execution output with pagination'));
|
||||
console.log(chalk.gray(' test-parse [args] Debug CLI argument parsing'));
|
||||
console.log();
|
||||
console.log(' Options:');
|
||||
|
||||
@@ -73,6 +73,7 @@ const ParamsSchema = z.object({
|
||||
noNative: z.boolean().optional(), // Force prompt concatenation instead of native resume
|
||||
category: z.enum(['user', 'internal', 'insight']).default('user'), // Execution category for tracking
|
||||
parentExecutionId: z.string().optional(), // Parent execution ID for fork/retry scenarios
|
||||
stream: z.boolean().default(false), // false = cache full output (default), true = stream output via callback
|
||||
});
|
||||
|
||||
// Execution category types
|
||||
@@ -863,24 +864,36 @@ async function executeCliTool(
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// Determine status
|
||||
// Determine status - prioritize output content over exit code
|
||||
let status: 'success' | 'error' | 'timeout' = 'success';
|
||||
if (timedOut) {
|
||||
status = 'timeout';
|
||||
} else if (code !== 0) {
|
||||
// Check if HTTP 429 but results exist (Gemini quirk)
|
||||
if (stderr.includes('429') && stdout.trim()) {
|
||||
// Non-zero exit code doesn't always mean failure
|
||||
// Check if there's valid output (AI response) - treat as success
|
||||
const hasValidOutput = stdout.trim().length > 0;
|
||||
const hasFatalError = stderr.includes('FATAL') ||
|
||||
stderr.includes('Authentication failed') ||
|
||||
stderr.includes('API key') ||
|
||||
stderr.includes('rate limit exceeded');
|
||||
|
||||
if (hasValidOutput && !hasFatalError) {
|
||||
// Has output and no fatal errors - treat as success despite exit code
|
||||
status = 'success';
|
||||
} else {
|
||||
status = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
// Create new turn
|
||||
// Create new turn - cache full output when not streaming (default)
|
||||
const shouldCache = !parsed.data.stream;
|
||||
const newTurnOutput = {
|
||||
stdout: stdout.substring(0, 10240), // Truncate to 10KB
|
||||
stderr: stderr.substring(0, 2048), // Truncate to 2KB
|
||||
truncated: stdout.length > 10240 || stderr.length > 2048
|
||||
stdout: stdout.substring(0, 10240), // Truncate preview to 10KB
|
||||
stderr: stderr.substring(0, 2048), // Truncate preview to 2KB
|
||||
truncated: stdout.length > 10240 || stderr.length > 2048,
|
||||
cached: shouldCache,
|
||||
stdout_full: shouldCache ? stdout : undefined,
|
||||
stderr_full: shouldCache ? stderr : undefined
|
||||
};
|
||||
|
||||
// Determine base turn number for merge scenarios
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface ConversationTurn {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
truncated: boolean;
|
||||
cached?: boolean;
|
||||
stdout_full?: string;
|
||||
stderr_full?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -315,6 +318,28 @@ export class CliHistoryStore {
|
||||
} catch (indexErr) {
|
||||
console.warn('[CLI History] Turns timestamp index creation warning:', (indexErr as Error).message);
|
||||
}
|
||||
|
||||
// Add cached output columns to turns table for non-streaming mode
|
||||
const turnsInfo = this.db.prepare('PRAGMA table_info(turns)').all() as Array<{ name: string }>;
|
||||
const hasCached = turnsInfo.some(col => col.name === 'cached');
|
||||
const hasStdoutFull = turnsInfo.some(col => col.name === 'stdout_full');
|
||||
const hasStderrFull = turnsInfo.some(col => col.name === 'stderr_full');
|
||||
|
||||
if (!hasCached) {
|
||||
console.log('[CLI History] Migrating database: adding cached column to turns table...');
|
||||
this.db.exec('ALTER TABLE turns ADD COLUMN cached INTEGER DEFAULT 0;');
|
||||
console.log('[CLI History] Migration complete: cached column added');
|
||||
}
|
||||
if (!hasStdoutFull) {
|
||||
console.log('[CLI History] Migrating database: adding stdout_full column to turns table...');
|
||||
this.db.exec('ALTER TABLE turns ADD COLUMN stdout_full TEXT;');
|
||||
console.log('[CLI History] Migration complete: stdout_full column added');
|
||||
}
|
||||
if (!hasStderrFull) {
|
||||
console.log('[CLI History] Migrating database: adding stderr_full column to turns table...');
|
||||
this.db.exec('ALTER TABLE turns ADD COLUMN stderr_full TEXT;');
|
||||
console.log('[CLI History] Migration complete: stderr_full column added');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CLI History] Migration error:', (err as Error).message);
|
||||
// Don't throw - allow the store to continue working with existing schema
|
||||
@@ -421,8 +446,8 @@ export class CliHistoryStore {
|
||||
`);
|
||||
|
||||
const upsertTurn = this.db.prepare(`
|
||||
INSERT INTO turns (conversation_id, turn_number, timestamp, prompt, duration_ms, status, exit_code, stdout, stderr, truncated)
|
||||
VALUES (@conversation_id, @turn_number, @timestamp, @prompt, @duration_ms, @status, @exit_code, @stdout, @stderr, @truncated)
|
||||
INSERT INTO turns (conversation_id, turn_number, timestamp, prompt, duration_ms, status, exit_code, stdout, stderr, truncated, cached, stdout_full, stderr_full)
|
||||
VALUES (@conversation_id, @turn_number, @timestamp, @prompt, @duration_ms, @status, @exit_code, @stdout, @stderr, @truncated, @cached, @stdout_full, @stderr_full)
|
||||
ON CONFLICT(conversation_id, turn_number) DO UPDATE SET
|
||||
timestamp = @timestamp,
|
||||
prompt = @prompt,
|
||||
@@ -431,7 +456,10 @@ export class CliHistoryStore {
|
||||
exit_code = @exit_code,
|
||||
stdout = @stdout,
|
||||
stderr = @stderr,
|
||||
truncated = @truncated
|
||||
truncated = @truncated,
|
||||
cached = @cached,
|
||||
stdout_full = @stdout_full,
|
||||
stderr_full = @stderr_full
|
||||
`);
|
||||
|
||||
const transaction = this.db.transaction(() => {
|
||||
@@ -463,7 +491,10 @@ export class CliHistoryStore {
|
||||
exit_code: turn.exit_code,
|
||||
stdout: turn.output.stdout,
|
||||
stderr: turn.output.stderr,
|
||||
truncated: turn.output.truncated ? 1 : 0
|
||||
truncated: turn.output.truncated ? 1 : 0,
|
||||
cached: turn.output.cached ? 1 : 0,
|
||||
stdout_full: turn.output.stdout_full || null,
|
||||
stderr_full: turn.output.stderr_full || null
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -507,7 +538,10 @@ export class CliHistoryStore {
|
||||
output: {
|
||||
stdout: t.stdout || '',
|
||||
stderr: t.stderr || '',
|
||||
truncated: !!t.truncated
|
||||
truncated: !!t.truncated,
|
||||
cached: !!t.cached,
|
||||
stdout_full: t.stdout_full || undefined,
|
||||
stderr_full: t.stderr_full || undefined
|
||||
}
|
||||
}))
|
||||
};
|
||||
@@ -533,6 +567,92 @@ export class CliHistoryStore {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated cached output for a conversation turn
|
||||
* @param conversationId - Conversation ID
|
||||
* @param turnNumber - Turn number (default: latest turn)
|
||||
* @param options - Pagination options
|
||||
*/
|
||||
getCachedOutput(
|
||||
conversationId: string,
|
||||
turnNumber?: number,
|
||||
options: {
|
||||
offset?: number; // Character offset (default: 0)
|
||||
limit?: number; // Max characters to return (default: 10000)
|
||||
outputType?: 'stdout' | 'stderr' | 'both'; // Which output to fetch
|
||||
} = {}
|
||||
): {
|
||||
conversationId: string;
|
||||
turnNumber: number;
|
||||
stdout?: { content: string; totalBytes: number; offset: number; hasMore: boolean };
|
||||
stderr?: { content: string; totalBytes: number; offset: number; hasMore: boolean };
|
||||
cached: boolean;
|
||||
prompt: string;
|
||||
status: string;
|
||||
timestamp: string;
|
||||
} | null {
|
||||
const { offset = 0, limit = 10000, outputType = 'both' } = options;
|
||||
|
||||
// Get turn (latest if not specified)
|
||||
let turn;
|
||||
if (turnNumber !== undefined) {
|
||||
turn = this.db.prepare(`
|
||||
SELECT * FROM turns WHERE conversation_id = ? AND turn_number = ?
|
||||
`).get(conversationId, turnNumber) as any;
|
||||
} else {
|
||||
turn = this.db.prepare(`
|
||||
SELECT * FROM turns WHERE conversation_id = ? ORDER BY turn_number DESC LIMIT 1
|
||||
`).get(conversationId) as any;
|
||||
}
|
||||
|
||||
if (!turn) return null;
|
||||
|
||||
const result: {
|
||||
conversationId: string;
|
||||
turnNumber: number;
|
||||
stdout?: { content: string; totalBytes: number; offset: number; hasMore: boolean };
|
||||
stderr?: { content: string; totalBytes: number; offset: number; hasMore: boolean };
|
||||
cached: boolean;
|
||||
prompt: string;
|
||||
status: string;
|
||||
timestamp: string;
|
||||
} = {
|
||||
conversationId,
|
||||
turnNumber: turn.turn_number,
|
||||
cached: !!turn.cached,
|
||||
prompt: turn.prompt,
|
||||
status: turn.status,
|
||||
timestamp: turn.timestamp
|
||||
};
|
||||
|
||||
// Use full output if cached, otherwise use truncated
|
||||
if (outputType === 'stdout' || outputType === 'both') {
|
||||
const fullStdout = turn.cached ? (turn.stdout_full || '') : (turn.stdout || '');
|
||||
const totalBytes = fullStdout.length;
|
||||
const content = fullStdout.substring(offset, offset + limit);
|
||||
result.stdout = {
|
||||
content,
|
||||
totalBytes,
|
||||
offset,
|
||||
hasMore: offset + limit < totalBytes
|
||||
};
|
||||
}
|
||||
|
||||
if (outputType === 'stderr' || outputType === 'both') {
|
||||
const fullStderr = turn.cached ? (turn.stderr_full || '') : (turn.stderr || '');
|
||||
const totalBytes = fullStderr.length;
|
||||
const content = fullStderr.substring(offset, offset + limit);
|
||||
result.stderr = {
|
||||
content,
|
||||
totalBytes,
|
||||
offset,
|
||||
hasMore: offset + limit < totalBytes
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query execution history
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user