mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-15 02:42:45 +08:00
Add benchmark results for fast3 and fast4, implement KeepAliveLspBridge, and add tests for staged strategies
- Added new benchmark result files: compare_2026-02-09_score_fast3.json and compare_2026-02-09_score_fast4.json. - Implemented KeepAliveLspBridge to maintain a persistent LSP connection across multiple queries, improving performance. - Created unit tests for staged clustering strategies in test_staged_stage3_fast_strategies.py, ensuring correct behavior of score and dir_rr strategies.
This commit is contained in:
153
ccw/src/core/routes/cli-sessions-routes.ts
Normal file
153
ccw/src/core/routes/cli-sessions-routes.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* CLI Sessions (PTY) Routes Module
|
||||
* Independent from existing /api/cli/* execution endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/cli-sessions
|
||||
* - POST /api/cli-sessions
|
||||
* - GET /api/cli-sessions/:sessionKey/buffer
|
||||
* - POST /api/cli-sessions/:sessionKey/send
|
||||
* - POST /api/cli-sessions/:sessionKey/execute
|
||||
* - POST /api/cli-sessions/:sessionKey/resize
|
||||
* - POST /api/cli-sessions/:sessionKey/close
|
||||
*/
|
||||
|
||||
import type { RouteContext } from './types.js';
|
||||
import { getCliSessionManager } from '../services/cli-session-manager.js';
|
||||
|
||||
export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, req, res, handlePostRequest, initialPath } = ctx;
|
||||
const manager = getCliSessionManager(process.cwd());
|
||||
|
||||
// GET /api/cli-sessions
|
||||
if (pathname === '/api/cli-sessions' && req.method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ sessions: manager.listSessions() }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/cli-sessions
|
||||
if (pathname === '/api/cli-sessions' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
const {
|
||||
workingDir,
|
||||
cols,
|
||||
rows,
|
||||
preferredShell,
|
||||
tool,
|
||||
model,
|
||||
resumeKey
|
||||
} = (body || {}) as any;
|
||||
|
||||
const session = manager.createSession({
|
||||
workingDir: workingDir || initialPath,
|
||||
cols: typeof cols === 'number' ? cols : undefined,
|
||||
rows: typeof rows === 'number' ? rows : undefined,
|
||||
preferredShell: preferredShell === 'pwsh' ? 'pwsh' : 'bash',
|
||||
tool,
|
||||
model,
|
||||
resumeKey
|
||||
});
|
||||
|
||||
return { success: true, session };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/cli-sessions/:sessionKey/buffer
|
||||
const bufferMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/buffer$/);
|
||||
if (bufferMatch && req.method === 'GET') {
|
||||
const sessionKey = decodeURIComponent(bufferMatch[1]);
|
||||
const session = manager.getSession(sessionKey);
|
||||
if (!session) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Session not found' }));
|
||||
return true;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ session, buffer: manager.getBuffer(sessionKey) }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/cli-sessions/:sessionKey/send
|
||||
const sendMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/send$/);
|
||||
if (sendMatch && req.method === 'POST') {
|
||||
const sessionKey = decodeURIComponent(sendMatch[1]);
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
const { text, appendNewline } = (body || {}) as any;
|
||||
if (typeof text !== 'string') {
|
||||
return { error: 'text is required', status: 400 };
|
||||
}
|
||||
manager.sendText(sessionKey, text, appendNewline !== false);
|
||||
return { success: true };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/cli-sessions/:sessionKey/execute
|
||||
const executeMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/execute$/);
|
||||
if (executeMatch && req.method === 'POST') {
|
||||
const sessionKey = decodeURIComponent(executeMatch[1]);
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
const {
|
||||
tool,
|
||||
prompt,
|
||||
mode,
|
||||
model,
|
||||
workingDir,
|
||||
category,
|
||||
resumeKey,
|
||||
resumeStrategy
|
||||
} = (body || {}) as any;
|
||||
|
||||
if (!tool || typeof tool !== 'string') {
|
||||
return { error: 'tool is required', status: 400 };
|
||||
}
|
||||
if (!prompt || typeof prompt !== 'string') {
|
||||
return { error: 'prompt is required', status: 400 };
|
||||
}
|
||||
|
||||
const result = manager.execute(sessionKey, {
|
||||
tool,
|
||||
prompt,
|
||||
mode,
|
||||
model,
|
||||
workingDir,
|
||||
category,
|
||||
resumeKey,
|
||||
resumeStrategy: resumeStrategy === 'promptConcat' ? 'promptConcat' : 'nativeResume'
|
||||
});
|
||||
|
||||
return { success: true, ...result };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/cli-sessions/:sessionKey/resize
|
||||
const resizeMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/resize$/);
|
||||
if (resizeMatch && req.method === 'POST') {
|
||||
const sessionKey = decodeURIComponent(resizeMatch[1]);
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
const { cols, rows } = (body || {}) as any;
|
||||
if (typeof cols !== 'number' || typeof rows !== 'number') {
|
||||
return { error: 'cols and rows are required', status: 400 };
|
||||
}
|
||||
manager.resize(sessionKey, cols, rows);
|
||||
return { success: true };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/cli-sessions/:sessionKey/close
|
||||
const closeMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/close$/);
|
||||
if (closeMatch && req.method === 'POST') {
|
||||
const sessionKey = decodeURIComponent(closeMatch[1]);
|
||||
manager.close(sessionKey);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
|
||||
// GET /api/queue/:id or /api/issues/queue/:id - Get specific queue by ID
|
||||
const queueDetailMatch = normalizedPath?.match(/^\/api\/queue\/([^/]+)$/);
|
||||
const reservedQueuePaths = ['history', 'reorder', 'switch', 'deactivate', 'merge', 'activate'];
|
||||
const reservedQueuePaths = ['history', 'reorder', 'move', 'switch', 'deactivate', 'merge', 'activate'];
|
||||
if (queueDetailMatch && req.method === 'GET' && !reservedQueuePaths.includes(queueDetailMatch[1])) {
|
||||
const queueId = queueDetailMatch[1];
|
||||
const queuesDir = join(issuesDir, 'queues');
|
||||
@@ -592,6 +592,89 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/queue/move - Move an item to a different execution_group (and optionally insert at index)
|
||||
if (normalizedPath === '/api/queue/move' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
const { itemId, toGroupId, toIndex } = body;
|
||||
if (!itemId || !toGroupId) {
|
||||
return { error: 'itemId and toGroupId required' };
|
||||
}
|
||||
|
||||
const queue = readQueue(issuesDir);
|
||||
const items = getQueueItems(queue);
|
||||
const isSolutionBased = isSolutionBasedQueue(queue);
|
||||
|
||||
const itemIndex = items.findIndex((i: any) => i.item_id === itemId);
|
||||
if (itemIndex === -1) return { error: `Item ${itemId} not found` };
|
||||
|
||||
const moved = { ...items[itemIndex] };
|
||||
const fromGroupId = moved.execution_group || 'ungrouped';
|
||||
|
||||
// Build per-group ordered lists based on current execution_order
|
||||
const groupToIds = new Map<string, string[]>();
|
||||
const sorted = [...items].sort((a: any, b: any) => (a.execution_order || 0) - (b.execution_order || 0));
|
||||
for (const it of sorted) {
|
||||
const gid = it.execution_group || 'ungrouped';
|
||||
if (!groupToIds.has(gid)) groupToIds.set(gid, []);
|
||||
groupToIds.get(gid)!.push(it.item_id);
|
||||
}
|
||||
|
||||
// Remove from old group
|
||||
const fromList = groupToIds.get(fromGroupId) || [];
|
||||
groupToIds.set(fromGroupId, fromList.filter((id) => id !== itemId));
|
||||
|
||||
// Insert into target group
|
||||
const targetList = groupToIds.get(toGroupId) || [];
|
||||
const insertAt = typeof toIndex === 'number' ? Math.max(0, Math.min(targetList.length, toIndex)) : targetList.length;
|
||||
const nextTarget = [...targetList];
|
||||
nextTarget.splice(insertAt, 0, itemId);
|
||||
groupToIds.set(toGroupId, nextTarget);
|
||||
|
||||
moved.execution_group = toGroupId;
|
||||
|
||||
const itemMap = new Map(items.map((i: any) => [i.item_id, i]));
|
||||
itemMap.set(itemId, moved);
|
||||
|
||||
const groupIds = Array.from(groupToIds.keys());
|
||||
groupIds.sort((a, b) => {
|
||||
const aGroup = parseInt(a.match(/\\d+/)?.[0] || '999');
|
||||
const bGroup = parseInt(b.match(/\\d+/)?.[0] || '999');
|
||||
if (aGroup !== bGroup) return aGroup - bGroup;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const nextItems: any[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const gid of groupIds) {
|
||||
const ids = groupToIds.get(gid) || [];
|
||||
for (const id of ids) {
|
||||
const it = itemMap.get(id);
|
||||
if (!it) continue;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
nextItems.push(it);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: append any missing items
|
||||
for (const it of items) {
|
||||
if (!seen.has(it.item_id)) nextItems.push(it);
|
||||
}
|
||||
|
||||
nextItems.forEach((it, idx) => { it.execution_order = idx + 1; });
|
||||
|
||||
if (isSolutionBased) {
|
||||
queue.solutions = nextItems;
|
||||
} else {
|
||||
queue.tasks = nextItems;
|
||||
}
|
||||
writeQueue(issuesDir, queue);
|
||||
|
||||
return { success: true, itemId, fromGroupId, toGroupId };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// DELETE /api/queue/:queueId/item/:itemId or /api/issues/queue/:queueId/item/:itemId
|
||||
const queueItemDeleteMatch = normalizedPath?.match(/^\/api\/queue\/([^/]+)\/item\/([^/]+)$/);
|
||||
if (queueItemDeleteMatch && req.method === 'DELETE') {
|
||||
|
||||
@@ -104,6 +104,28 @@ export interface PromptTemplateNodeData {
|
||||
*/
|
||||
mode?: ExecutionMode;
|
||||
|
||||
/**
|
||||
* Delivery target for CLI-mode execution.
|
||||
* - newExecution: spawn a fresh CLI execution (default)
|
||||
* - sendToSession: route to a PTY session (tmux-like send)
|
||||
*/
|
||||
delivery?: 'newExecution' | 'sendToSession';
|
||||
|
||||
/**
|
||||
* When delivery=sendToSession, route execution to this PTY session key.
|
||||
*/
|
||||
targetSessionKey?: string;
|
||||
|
||||
/**
|
||||
* Optional logical resume key for chaining executions.
|
||||
*/
|
||||
resumeKey?: string;
|
||||
|
||||
/**
|
||||
* Optional resume mapping strategy.
|
||||
*/
|
||||
resumeStrategy?: 'nativeResume' | 'promptConcat';
|
||||
|
||||
/**
|
||||
* References to outputs from previous steps
|
||||
* Use the outputName values from earlier nodes
|
||||
|
||||
@@ -8,6 +8,7 @@ import { resolvePath, getRecentPaths, normalizePathForDisplay } from '../utils/p
|
||||
import { handleStatusRoutes } from './routes/status-routes.js';
|
||||
import { handleCliRoutes, cleanupStaleExecutions } from './routes/cli-routes.js';
|
||||
import { handleCliSettingsRoutes } from './routes/cli-settings-routes.js';
|
||||
import { handleCliSessionsRoutes } from './routes/cli-sessions-routes.js';
|
||||
import { handleProviderRoutes } from './routes/provider-routes.js';
|
||||
import { handleMemoryRoutes } from './routes/memory-routes.js';
|
||||
import { handleCoreMemoryRoutes } from './routes/core-memory-routes.js';
|
||||
@@ -591,6 +592,11 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
if (await handleDashboardRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// CLI sessions (PTY) routes (/api/cli-sessions/*) - independent from /api/cli/*
|
||||
if (pathname.startsWith('/api/cli-sessions')) {
|
||||
if (await handleCliSessionsRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// CLI routes (/api/cli/*)
|
||||
if (pathname.startsWith('/api/cli/')) {
|
||||
// CLI Settings routes first (more specific path /api/cli/settings/*)
|
||||
|
||||
110
ccw/src/core/services/cli-session-command-builder.ts
Normal file
110
ccw/src/core/services/cli-session-command-builder.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import path from 'path';
|
||||
|
||||
export type CliSessionShellKind = 'wsl-bash' | 'git-bash' | 'pwsh';
|
||||
|
||||
export type CliSessionResumeStrategy = 'nativeResume' | 'promptConcat';
|
||||
|
||||
export interface CliSessionExecuteCommandInput {
|
||||
projectRoot: string;
|
||||
shellKind: CliSessionShellKind;
|
||||
tool: string;
|
||||
prompt: string;
|
||||
mode?: 'analysis' | 'write' | 'auto';
|
||||
model?: string;
|
||||
workingDir?: string;
|
||||
category?: 'user' | 'internal' | 'insight';
|
||||
resumeStrategy?: CliSessionResumeStrategy;
|
||||
prevExecutionId?: string;
|
||||
executionId: string;
|
||||
}
|
||||
|
||||
export interface CliSessionExecuteCommandOutput {
|
||||
command: string;
|
||||
}
|
||||
|
||||
function toPosixPath(p: string): string {
|
||||
return p.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function toWslPath(winPath: string): string {
|
||||
const normalized = winPath.replace(/\\/g, '/').replace(/\/+/g, '/');
|
||||
const driveMatch = normalized.match(/^([a-zA-Z]):\/(.*)$/);
|
||||
if (!driveMatch) return normalized;
|
||||
return `/mnt/${driveMatch[1].toLowerCase()}/${driveMatch[2]}`;
|
||||
}
|
||||
|
||||
function escapeArg(value: string): string {
|
||||
// Minimal quoting that works in pwsh + bash.
|
||||
// We intentionally avoid escaping with platform-specific rules; values are expected to be simple (paths/tool/model).
|
||||
if (!value) return '""';
|
||||
if (/[\s"]/g.test(value)) {
|
||||
return `"${value.replaceAll('"', '\\"')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildCliSessionExecuteCommand(input: CliSessionExecuteCommandInput): CliSessionExecuteCommandOutput {
|
||||
const {
|
||||
projectRoot,
|
||||
shellKind,
|
||||
tool,
|
||||
prompt,
|
||||
mode = 'analysis',
|
||||
model,
|
||||
workingDir,
|
||||
category = 'user',
|
||||
resumeStrategy = 'nativeResume',
|
||||
prevExecutionId,
|
||||
executionId
|
||||
} = input;
|
||||
|
||||
const nodeExe = shellKind === 'wsl-bash' ? 'node.exe' : 'node';
|
||||
|
||||
const ccwScriptWin = path.join(projectRoot, 'ccw', 'bin', 'ccw.js');
|
||||
const ccwScriptPosix = toPosixPath(ccwScriptWin);
|
||||
const ccwScriptWsl = toWslPath(ccwScriptPosix);
|
||||
|
||||
// In WSL we prefer running the Windows Node (`node.exe`) for compatibility
|
||||
// (no dependency on Node being installed inside the Linux distro). However,
|
||||
// Windows executables do not reliably understand `/mnt/*` paths, so we convert
|
||||
// to Windows paths at runtime via `wslpath -w`.
|
||||
const wslPreambleParts: string[] = [];
|
||||
if (shellKind === 'wsl-bash') {
|
||||
wslPreambleParts.push(`CCW_WIN=$(wslpath -w ${escapeArg(ccwScriptWsl)})`);
|
||||
if (workingDir) {
|
||||
const wdWsl = toWslPath(toPosixPath(workingDir));
|
||||
wslPreambleParts.push(`WD_WIN=$(wslpath -w ${escapeArg(wdWsl)})`);
|
||||
}
|
||||
}
|
||||
const wslPreamble = wslPreambleParts.length > 0 ? `${wslPreambleParts.join('; ')}; ` : '';
|
||||
|
||||
const cdArg =
|
||||
workingDir
|
||||
? shellKind === 'wsl-bash'
|
||||
? ' --cd "$WD_WIN"'
|
||||
: ` --cd ${escapeArg(toPosixPath(workingDir))}`
|
||||
: '';
|
||||
const modelArg = model ? ` --model ${escapeArg(model)}` : '';
|
||||
const resumeArg = prevExecutionId ? ` --resume ${escapeArg(prevExecutionId)}` : '';
|
||||
const noNativeArg = resumeStrategy === 'promptConcat' ? ' --no-native' : '';
|
||||
|
||||
// Pipe prompt through stdin so multi-line works without shell-dependent quoting.
|
||||
// Base64 avoids escaping issues; decode is performed by node itself.
|
||||
const promptB64 = Buffer.from(prompt, 'utf8').toString('base64');
|
||||
const decodeCmd = `${nodeExe} -e "process.stdout.write(Buffer.from('${promptB64}','base64'))"`;
|
||||
|
||||
const ccwTarget = shellKind === 'wsl-bash' ? '"$CCW_WIN"' : escapeArg(ccwScriptPosix);
|
||||
const ccwCmd =
|
||||
`${nodeExe} ${ccwTarget} cli` +
|
||||
` --tool ${escapeArg(tool)}` +
|
||||
` --mode ${escapeArg(mode)}` +
|
||||
`${modelArg}` +
|
||||
`${cdArg}` +
|
||||
` --category ${escapeArg(category)}` +
|
||||
` --stream` +
|
||||
` --id ${escapeArg(executionId)}` +
|
||||
`${resumeArg}` +
|
||||
`${noNativeArg}`;
|
||||
|
||||
return { command: `${wslPreamble}${decodeCmd} | ${ccwCmd}` };
|
||||
}
|
||||
380
ccw/src/core/services/cli-session-manager.ts
Normal file
380
ccw/src/core/services/cli-session-manager.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import { existsSync } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as nodePty from 'node-pty';
|
||||
import { EventEmitter } from 'events';
|
||||
import { broadcastToClients } from '../websocket.js';
|
||||
import {
|
||||
buildCliSessionExecuteCommand,
|
||||
type CliSessionShellKind,
|
||||
type CliSessionResumeStrategy
|
||||
} from './cli-session-command-builder.js';
|
||||
import { getCliSessionPolicy } from './cli-session-policy.js';
|
||||
|
||||
export interface CliSession {
|
||||
sessionKey: string;
|
||||
shellKind: CliSessionShellKind;
|
||||
workingDir: string;
|
||||
tool?: string;
|
||||
model?: string;
|
||||
resumeKey?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateCliSessionOptions {
|
||||
workingDir: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
preferredShell?: 'bash' | 'pwsh';
|
||||
tool?: string;
|
||||
model?: string;
|
||||
resumeKey?: string;
|
||||
}
|
||||
|
||||
export interface ExecuteInCliSessionOptions {
|
||||
tool: string;
|
||||
prompt: string;
|
||||
mode?: 'analysis' | 'write' | 'auto';
|
||||
model?: string;
|
||||
workingDir?: string;
|
||||
category?: 'user' | 'internal' | 'insight';
|
||||
resumeKey?: string;
|
||||
resumeStrategy?: CliSessionResumeStrategy;
|
||||
}
|
||||
|
||||
export interface CliSessionOutputEvent {
|
||||
sessionKey: string;
|
||||
data: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface CliSessionInternal extends CliSession {
|
||||
pty: nodePty.IPty;
|
||||
buffer: string[];
|
||||
bufferBytes: number;
|
||||
lastActivityAt: number;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function createSessionKey(): string {
|
||||
const suffix = randomBytes(4).toString('hex');
|
||||
return `cli-session-${Date.now()}-${suffix}`;
|
||||
}
|
||||
|
||||
function normalizeWorkingDir(workingDir: string): string {
|
||||
return path.resolve(workingDir);
|
||||
}
|
||||
|
||||
function findGitBashExe(): string | null {
|
||||
const candidates = [
|
||||
'C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe',
|
||||
'C:\\\\Program Files\\\\Git\\\\usr\\\\bin\\\\bash.exe',
|
||||
'C:\\\\Program Files (x86)\\\\Git\\\\bin\\\\bash.exe',
|
||||
'C:\\\\Program Files (x86)\\\\Git\\\\usr\\\\bin\\\\bash.exe'
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
try {
|
||||
const where = spawnSync('where', ['bash'], { encoding: 'utf8', windowsHide: true });
|
||||
if (where.status === 0) {
|
||||
const lines = (where.stdout || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
||||
const gitBash = lines.find(l => /\\Git\\.*\\bash\.exe$/i.test(l));
|
||||
return gitBash || (lines[0] || null);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isWslAvailable(): boolean {
|
||||
try {
|
||||
const probe = spawnSync('wsl.exe', ['-e', 'bash', '-lc', 'echo ok'], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
timeout: 1500
|
||||
});
|
||||
return probe.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pickShell(preferred: 'bash' | 'pwsh'): { shellKind: CliSessionShellKind; file: string; args: string[] } {
|
||||
if (os.platform() === 'win32') {
|
||||
if (preferred === 'bash') {
|
||||
if (isWslAvailable()) {
|
||||
return { shellKind: 'wsl-bash', file: 'wsl.exe', args: ['-e', 'bash', '-l', '-i'] };
|
||||
}
|
||||
const gitBash = findGitBashExe();
|
||||
if (gitBash) {
|
||||
return { shellKind: 'git-bash', file: gitBash, args: ['-l', '-i'] };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: PowerShell (pwsh preferred, windows powershell as final)
|
||||
const pwsh = spawnSync('where', ['pwsh'], { encoding: 'utf8', windowsHide: true });
|
||||
if (pwsh.status === 0) {
|
||||
return { shellKind: 'pwsh', file: 'pwsh', args: ['-NoLogo'] };
|
||||
}
|
||||
return { shellKind: 'pwsh', file: 'powershell', args: ['-NoLogo'] };
|
||||
}
|
||||
|
||||
// Non-Windows: keep it simple (bash-first)
|
||||
if (preferred === 'pwsh') {
|
||||
return { shellKind: 'pwsh', file: 'pwsh', args: ['-NoLogo'] };
|
||||
}
|
||||
return { shellKind: 'git-bash', file: 'bash', args: ['-l', '-i'] };
|
||||
}
|
||||
|
||||
function toWslPath(winPath: string): string {
|
||||
const normalized = winPath.replace(/\\/g, '/');
|
||||
const driveMatch = normalized.match(/^([a-zA-Z]):\/(.*)$/);
|
||||
if (!driveMatch) return normalized;
|
||||
return `/mnt/${driveMatch[1].toLowerCase()}/${driveMatch[2]}`;
|
||||
}
|
||||
|
||||
export class CliSessionManager {
|
||||
private sessions = new Map<string, CliSessionInternal>();
|
||||
private resumeKeyLastExecution = new Map<string, string>();
|
||||
private projectRoot: string;
|
||||
private emitter = new EventEmitter();
|
||||
private maxBufferBytes: number;
|
||||
|
||||
constructor(projectRoot: string) {
|
||||
this.projectRoot = projectRoot;
|
||||
this.maxBufferBytes = getCliSessionPolicy().maxBufferBytes;
|
||||
}
|
||||
|
||||
listSessions(): CliSession[] {
|
||||
return Array.from(this.sessions.values()).map(({ pty: _pty, buffer: _buffer, bufferBytes: _bytes, ...rest }) => rest);
|
||||
}
|
||||
|
||||
getSession(sessionKey: string): CliSession | null {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) return null;
|
||||
const { pty: _pty, buffer: _buffer, bufferBytes: _bytes, ...rest } = session;
|
||||
return rest;
|
||||
}
|
||||
|
||||
getBuffer(sessionKey: string): string {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) return '';
|
||||
return session.buffer.join('');
|
||||
}
|
||||
|
||||
createSession(options: CreateCliSessionOptions): CliSession {
|
||||
const workingDir = normalizeWorkingDir(options.workingDir);
|
||||
const preferredShell = options.preferredShell ?? 'bash';
|
||||
const { shellKind, file, args } = pickShell(preferredShell);
|
||||
|
||||
const sessionKey = createSessionKey();
|
||||
const createdAt = nowIso();
|
||||
|
||||
const pty = nodePty.spawn(file, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: options.cols ?? 120,
|
||||
rows: options.rows ?? 30,
|
||||
cwd: workingDir,
|
||||
env: process.env as Record<string, string>
|
||||
});
|
||||
|
||||
const session: CliSessionInternal = {
|
||||
sessionKey,
|
||||
shellKind,
|
||||
workingDir,
|
||||
tool: options.tool,
|
||||
model: options.model,
|
||||
resumeKey: options.resumeKey,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
pty,
|
||||
buffer: [],
|
||||
bufferBytes: 0,
|
||||
lastActivityAt: Date.now(),
|
||||
};
|
||||
|
||||
pty.onData((data) => {
|
||||
this.appendToBuffer(sessionKey, data);
|
||||
const now = Date.now();
|
||||
const s = this.sessions.get(sessionKey);
|
||||
if (s) {
|
||||
s.updatedAt = nowIso();
|
||||
s.lastActivityAt = now;
|
||||
}
|
||||
|
||||
this.emitter.emit('output', {
|
||||
sessionKey,
|
||||
data,
|
||||
timestamp: nowIso(),
|
||||
} satisfies CliSessionOutputEvent);
|
||||
broadcastToClients({
|
||||
type: 'CLI_SESSION_OUTPUT',
|
||||
payload: {
|
||||
sessionKey,
|
||||
data,
|
||||
timestamp: nowIso()
|
||||
} satisfies CliSessionOutputEvent
|
||||
});
|
||||
});
|
||||
|
||||
pty.onExit(({ exitCode, signal }) => {
|
||||
this.sessions.delete(sessionKey);
|
||||
broadcastToClients({
|
||||
type: 'CLI_SESSION_CLOSED',
|
||||
payload: {
|
||||
sessionKey,
|
||||
exitCode,
|
||||
signal,
|
||||
timestamp: nowIso()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.sessions.set(sessionKey, session);
|
||||
|
||||
// WSL often ignores Windows cwd; best-effort cd to mounted path.
|
||||
if (shellKind === 'wsl-bash') {
|
||||
const wslCwd = toWslPath(workingDir.replace(/\\/g, '/'));
|
||||
this.sendText(sessionKey, `cd ${wslCwd}`, true);
|
||||
}
|
||||
|
||||
broadcastToClients({
|
||||
type: 'CLI_SESSION_CREATED',
|
||||
payload: { session: this.getSession(sessionKey), timestamp: nowIso() }
|
||||
});
|
||||
|
||||
return this.getSession(sessionKey)!;
|
||||
}
|
||||
|
||||
sendText(sessionKey: string, text: string, appendNewline: boolean): void {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${sessionKey}`);
|
||||
}
|
||||
session.updatedAt = nowIso();
|
||||
session.lastActivityAt = Date.now();
|
||||
session.pty.write(text);
|
||||
if (appendNewline) {
|
||||
session.pty.write('\r');
|
||||
}
|
||||
}
|
||||
|
||||
resize(sessionKey: string, cols: number, rows: number): void {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${sessionKey}`);
|
||||
}
|
||||
session.updatedAt = nowIso();
|
||||
session.lastActivityAt = Date.now();
|
||||
session.pty.resize(cols, rows);
|
||||
}
|
||||
|
||||
close(sessionKey: string): void {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) return;
|
||||
session.updatedAt = nowIso();
|
||||
session.lastActivityAt = Date.now();
|
||||
try {
|
||||
session.pty.kill();
|
||||
} finally {
|
||||
this.sessions.delete(sessionKey);
|
||||
broadcastToClients({ type: 'CLI_SESSION_CLOSED', payload: { sessionKey, timestamp: nowIso() } });
|
||||
}
|
||||
}
|
||||
|
||||
execute(sessionKey: string, options: ExecuteInCliSessionOptions): { executionId: string; command: string } {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${sessionKey}`);
|
||||
}
|
||||
session.updatedAt = nowIso();
|
||||
session.lastActivityAt = Date.now();
|
||||
|
||||
const resumeKey = options.resumeKey ?? session.resumeKey;
|
||||
const resumeMapKey = resumeKey ? `${options.tool}:${resumeKey}` : null;
|
||||
const prevExecutionId = resumeMapKey ? this.resumeKeyLastExecution.get(resumeMapKey) : undefined;
|
||||
|
||||
const executionId = resumeKey
|
||||
? `${resumeKey}-${Date.now()}`
|
||||
: `exec-${Date.now()}-${randomBytes(3).toString('hex')}`;
|
||||
|
||||
const { command } = buildCliSessionExecuteCommand({
|
||||
projectRoot: this.projectRoot,
|
||||
shellKind: session.shellKind,
|
||||
tool: options.tool,
|
||||
prompt: options.prompt,
|
||||
mode: options.mode,
|
||||
model: options.model,
|
||||
workingDir: options.workingDir ?? session.workingDir,
|
||||
category: options.category,
|
||||
resumeStrategy: options.resumeStrategy,
|
||||
prevExecutionId,
|
||||
executionId
|
||||
});
|
||||
|
||||
// Best-effort: preemptively update mapping so subsequent queue items can chain.
|
||||
if (resumeMapKey) {
|
||||
this.resumeKeyLastExecution.set(resumeMapKey, executionId);
|
||||
}
|
||||
|
||||
this.sendText(sessionKey, command, true);
|
||||
|
||||
broadcastToClients({
|
||||
type: 'CLI_SESSION_EXECUTE',
|
||||
payload: { sessionKey, executionId, command, timestamp: nowIso() }
|
||||
});
|
||||
|
||||
return { executionId, command };
|
||||
}
|
||||
|
||||
private appendToBuffer(sessionKey: string, chunk: string): void {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) return;
|
||||
|
||||
session.buffer.push(chunk);
|
||||
session.bufferBytes += Buffer.byteLength(chunk, 'utf8');
|
||||
|
||||
while (session.bufferBytes > this.maxBufferBytes && session.buffer.length > 0) {
|
||||
const removed = session.buffer.shift();
|
||||
if (removed) session.bufferBytes -= Buffer.byteLength(removed, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
onOutput(listener: (event: CliSessionOutputEvent) => void): () => void {
|
||||
const handler = (event: CliSessionOutputEvent) => listener(event);
|
||||
this.emitter.on('output', handler);
|
||||
return () => this.emitter.off('output', handler);
|
||||
}
|
||||
|
||||
closeIdleSessions(idleTimeoutMs: number): number {
|
||||
if (idleTimeoutMs <= 0) return 0;
|
||||
const now = Date.now();
|
||||
let closed = 0;
|
||||
for (const s of this.sessions.values()) {
|
||||
if (now - s.lastActivityAt >= idleTimeoutMs) {
|
||||
this.close(s.sessionKey);
|
||||
closed += 1;
|
||||
}
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
}
|
||||
|
||||
const managersByRoot = new Map<string, CliSessionManager>();
|
||||
|
||||
export function getCliSessionManager(projectRoot: string = process.cwd()): CliSessionManager {
|
||||
const resolved = path.resolve(projectRoot);
|
||||
const existing = managersByRoot.get(resolved);
|
||||
if (existing) return existing;
|
||||
const created = new CliSessionManager(resolved);
|
||||
managersByRoot.set(resolved, created);
|
||||
return created;
|
||||
}
|
||||
55
ccw/src/core/services/cli-session-policy.ts
Normal file
55
ccw/src/core/services/cli-session-policy.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import os from 'os';
|
||||
|
||||
export interface CliSessionPolicy {
|
||||
allowedTools: string[];
|
||||
maxSessions: number;
|
||||
idleTimeoutMs: number;
|
||||
allowWorkingDirOutsideProject: boolean;
|
||||
maxBufferBytes: number;
|
||||
rateLimit: {
|
||||
createPerMinute: number;
|
||||
executePerMinute: number;
|
||||
sendBytesPerMinute: number;
|
||||
resizePerMinute: number;
|
||||
};
|
||||
}
|
||||
|
||||
function parseIntEnv(name: string, fallback: number): number {
|
||||
const raw = (process.env[name] ?? '').trim();
|
||||
if (!raw) return fallback;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function parseBoolEnv(name: string, fallback: boolean): boolean {
|
||||
const raw = (process.env[name] ?? '').trim().toLowerCase();
|
||||
if (!raw) return fallback;
|
||||
if (raw === '1' || raw === 'true' || raw === 'yes' || raw === 'y') return true;
|
||||
if (raw === '0' || raw === 'false' || raw === 'no' || raw === 'n') return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function getCliSessionPolicy(): CliSessionPolicy {
|
||||
const defaultAllowedTools = ['claude', 'codex', 'gemini', 'qwen', 'opencode'];
|
||||
const allowedToolsRaw = (process.env.CCW_CLI_SESSIONS_ALLOWED_TOOLS ?? '').trim();
|
||||
const allowedTools = allowedToolsRaw
|
||||
? allowedToolsRaw.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
: defaultAllowedTools;
|
||||
|
||||
const maxSessionsDefault = os.platform() === 'win32' ? 6 : 8;
|
||||
|
||||
return {
|
||||
allowedTools,
|
||||
maxSessions: parseIntEnv('CCW_CLI_SESSIONS_MAX', maxSessionsDefault),
|
||||
idleTimeoutMs: parseIntEnv('CCW_CLI_SESSIONS_IDLE_TIMEOUT_MS', 30 * 60_000),
|
||||
allowWorkingDirOutsideProject: parseBoolEnv('CCW_CLI_SESSIONS_ALLOW_OUTSIDE_PROJECT', false),
|
||||
maxBufferBytes: parseIntEnv('CCW_CLI_SESSIONS_MAX_BUFFER_BYTES', 2 * 1024 * 1024),
|
||||
rateLimit: {
|
||||
createPerMinute: parseIntEnv('CCW_CLI_SESSIONS_RL_CREATE_PER_MIN', 12),
|
||||
executePerMinute: parseIntEnv('CCW_CLI_SESSIONS_RL_EXECUTE_PER_MIN', 60),
|
||||
sendBytesPerMinute: parseIntEnv('CCW_CLI_SESSIONS_RL_SEND_BYTES_PER_MIN', 256 * 1024),
|
||||
resizePerMinute: parseIntEnv('CCW_CLI_SESSIONS_RL_RESIZE_PER_MIN', 120),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { broadcastToClients } from '../websocket.js';
|
||||
import { executeCliTool } from '../../tools/cli-executor-core.js';
|
||||
import { getCliSessionManager } from './cli-session-manager.js';
|
||||
import type {
|
||||
Flow,
|
||||
FlowNode,
|
||||
@@ -244,6 +245,44 @@ export class NodeRunner {
|
||||
const mode = this.determineCliMode(data.mode);
|
||||
|
||||
try {
|
||||
// Optional: route execution to a PTY session (tmux-like send)
|
||||
if (data.delivery === 'sendToSession') {
|
||||
const targetSessionKey = data.targetSessionKey;
|
||||
if (!targetSessionKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'delivery=sendToSession requires targetSessionKey'
|
||||
};
|
||||
}
|
||||
|
||||
const manager = getCliSessionManager(process.cwd());
|
||||
const routed = manager.execute(targetSessionKey, {
|
||||
tool,
|
||||
prompt: instruction,
|
||||
mode,
|
||||
workingDir: this.context.workingDir,
|
||||
resumeKey: data.resumeKey,
|
||||
resumeStrategy: data.resumeStrategy === 'promptConcat' ? 'promptConcat' : 'nativeResume'
|
||||
});
|
||||
|
||||
const outputKey = data.outputName || `${node.id}_output`;
|
||||
this.context.variables[outputKey] = {
|
||||
delivery: 'sendToSession',
|
||||
sessionKey: targetSessionKey,
|
||||
executionId: routed.executionId,
|
||||
command: routed.command
|
||||
};
|
||||
this.context.variables[`${node.id}_executionId`] = routed.executionId;
|
||||
this.context.variables[`${node.id}_command`] = routed.command;
|
||||
this.context.variables[`${node.id}_success`] = true;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: routed.command,
|
||||
exitCode: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Execute via CLI tool
|
||||
const result = await executeCliTool({
|
||||
tool,
|
||||
|
||||
49
ccw/src/core/services/rate-limiter.ts
Normal file
49
ccw/src/core/services/rate-limiter.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export interface RateLimitResult {
|
||||
ok: boolean;
|
||||
remaining: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
interface BucketState {
|
||||
tokens: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple fixed-window token bucket (in-memory).
|
||||
* Good enough for local dashboard usage; not suitable for multi-process deployments.
|
||||
*/
|
||||
export class RateLimiter {
|
||||
private buckets = new Map<string, BucketState>();
|
||||
private limit: number;
|
||||
private windowMs: number;
|
||||
|
||||
constructor(opts: { limit: number; windowMs: number }) {
|
||||
this.limit = Math.max(0, opts.limit);
|
||||
this.windowMs = Math.max(1, opts.windowMs);
|
||||
}
|
||||
|
||||
consume(key: string, cost: number = 1): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const safeCost = Math.max(0, Math.floor(cost));
|
||||
const existing = this.buckets.get(key);
|
||||
|
||||
if (!existing || now >= existing.resetAt) {
|
||||
const resetAt = now + this.windowMs;
|
||||
const nextTokens = this.limit - safeCost;
|
||||
const ok = nextTokens >= 0;
|
||||
const tokens = ok ? nextTokens : this.limit;
|
||||
this.buckets.set(key, { tokens, resetAt });
|
||||
return { ok, remaining: Math.max(0, ok ? tokens : 0), resetAt };
|
||||
}
|
||||
|
||||
const nextTokens = existing.tokens - safeCost;
|
||||
if (nextTokens < 0) {
|
||||
return { ok: false, remaining: Math.max(0, existing.tokens), resetAt: existing.resetAt };
|
||||
}
|
||||
|
||||
existing.tokens = nextTokens;
|
||||
return { ok: true, remaining: nextTokens, resetAt: existing.resetAt };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user