mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
Add benchmark results and tests for LSP graph builder and staged search
- Introduced a new benchmark results file for performance comparison on 2026-02-09. - Added a test for LspGraphBuilder to ensure it does not expand nodes at maximum depth. - Created a test for the staged search pipeline to validate fallback behavior when stage 1 returns empty results.
This commit is contained in:
@@ -310,14 +310,14 @@ export function IssueBoardPanel() {
|
||||
preferredShell: 'bash',
|
||||
tool: autoStart.tool,
|
||||
resumeKey: issueId,
|
||||
});
|
||||
}, projectPath);
|
||||
await executeInCliSession(created.session.sessionKey, {
|
||||
tool: autoStart.tool,
|
||||
prompt: buildIssueAutoPrompt({ ...issue, status: destStatus }),
|
||||
mode: autoStart.mode,
|
||||
resumeKey: issueId,
|
||||
resumeStrategy: autoStart.resumeStrategy,
|
||||
});
|
||||
}, projectPath);
|
||||
} catch (e) {
|
||||
setOptimisticError(`Auto-start failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
@@ -328,7 +328,7 @@ export function IssueBoardPanel() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[issues, idsByStatus, updateIssue]
|
||||
[autoStart, issues, idsByStatus, projectPath, updateIssue]
|
||||
);
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Plus, RefreshCw, XCircle } from 'lucide-react';
|
||||
import { Copy, Plus, RefreshCw, Share2, XCircle } from 'lucide-react';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -16,6 +16,7 @@ import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import {
|
||||
closeCliSession,
|
||||
createCliSession,
|
||||
createCliSessionShareToken,
|
||||
executeInCliSession,
|
||||
fetchCliSessionBuffer,
|
||||
fetchCliSessions,
|
||||
@@ -53,6 +54,7 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
const [resumeStrategy, setResumeStrategy] = useState<ResumeStrategy>('nativeResume');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [shareUrl, setShareUrl] = useState<string>('');
|
||||
|
||||
const terminalHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const xtermRef = useRef<XTerm | null>(null);
|
||||
@@ -69,7 +71,7 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
pendingInputRef.current = '';
|
||||
if (!pending) return;
|
||||
try {
|
||||
await sendCliSessionText(sessionKey, { text: pending, appendNewline: false });
|
||||
await sendCliSessionText(sessionKey, { text: pending, appendNewline: false }, projectPath || undefined);
|
||||
} catch (e) {
|
||||
// Ignore transient failures (WS output still shows process state)
|
||||
}
|
||||
@@ -86,13 +88,13 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
useEffect(() => {
|
||||
setIsLoadingSessions(true);
|
||||
setError(null);
|
||||
fetchCliSessions()
|
||||
fetchCliSessions(projectPath || undefined)
|
||||
.then((r) => {
|
||||
setSessions(r.sessions as unknown as CliSession[]);
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setIsLoadingSessions(false));
|
||||
}, [setSessions]);
|
||||
}, [projectPath, setSessions]);
|
||||
|
||||
// Auto-select a session if none selected yet
|
||||
useEffect(() => {
|
||||
@@ -152,7 +154,7 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
if (!selectedSessionKey) return;
|
||||
clearOutput(selectedSessionKey);
|
||||
|
||||
fetchCliSessionBuffer(selectedSessionKey)
|
||||
fetchCliSessionBuffer(selectedSessionKey, projectPath || undefined)
|
||||
.then(({ buffer }) => {
|
||||
setBuffer(selectedSessionKey, buffer || '');
|
||||
})
|
||||
@@ -162,7 +164,7 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
.finally(() => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
}, [selectedSessionKey, setBuffer, clearOutput]);
|
||||
}, [selectedSessionKey, projectPath, setBuffer, clearOutput]);
|
||||
|
||||
// Stream new output chunks into xterm
|
||||
useEffect(() => {
|
||||
@@ -192,7 +194,7 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
if (selectedSessionKey) {
|
||||
void (async () => {
|
||||
try {
|
||||
await resizeCliSession(selectedSessionKey, { cols: term.cols, rows: term.rows });
|
||||
await resizeCliSession(selectedSessionKey, { cols: term.cols, rows: term.rows }, projectPath || undefined);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -203,7 +205,7 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(host);
|
||||
return () => ro.disconnect();
|
||||
}, [selectedSessionKey]);
|
||||
}, [selectedSessionKey, projectPath]);
|
||||
|
||||
const handleCreateSession = async () => {
|
||||
setIsCreating(true);
|
||||
@@ -217,7 +219,7 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
tool,
|
||||
model: undefined,
|
||||
resumeKey,
|
||||
});
|
||||
}, projectPath || undefined);
|
||||
upsertSession(created.session as unknown as CliSession);
|
||||
setSelectedSessionKey(created.session.sessionKey);
|
||||
} catch (e) {
|
||||
@@ -232,7 +234,7 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
setIsClosing(true);
|
||||
setError(null);
|
||||
try {
|
||||
await closeCliSession(selectedSessionKey);
|
||||
await closeCliSession(selectedSessionKey, projectPath || undefined);
|
||||
setSelectedSessionKey('');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -254,7 +256,7 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
resumeKey: resumeKey.trim() || undefined,
|
||||
resumeStrategy,
|
||||
category: 'user',
|
||||
});
|
||||
}, projectPath || undefined);
|
||||
setPrompt('');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -267,7 +269,7 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
setIsLoadingSessions(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await fetchCliSessions();
|
||||
const r = await fetchCliSessions(projectPath || undefined);
|
||||
setSessions(r.sessions as unknown as CliSession[]);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -276,6 +278,31 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateShareLink = async () => {
|
||||
if (!selectedSessionKey) return;
|
||||
setError(null);
|
||||
setShareUrl('');
|
||||
try {
|
||||
const r = await createCliSessionShareToken(selectedSessionKey, { mode: 'read' }, projectPath || undefined);
|
||||
const url = new URL(window.location.href);
|
||||
const base = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, '');
|
||||
url.pathname = `${base}/cli-sessions/share`;
|
||||
url.search = `sessionKey=${encodeURIComponent(selectedSessionKey)}&shareToken=${encodeURIComponent(r.shareToken)}`;
|
||||
setShareUrl(url.toString());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyShareLink = async () => {
|
||||
if (!shareUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -317,8 +344,23 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
<XCircle className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.terminal.session.close' })}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onClick={handleCreateShareLink} disabled={!selectedSessionKey}>
|
||||
<Share2 className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.terminal.session.share' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{shareUrl && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value={shareUrl} readOnly />
|
||||
<Button variant="outline" onClick={handleCopyShareLink}>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'common.actions.copy' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.terminal.exec.tool' })}</div>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function QueueExecuteInSession({ item, className }: { item: QueueItem; cl
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await fetchCliSessions();
|
||||
const r = await fetchCliSessions(projectPath || undefined);
|
||||
setSessions(r.sessions as unknown as CliSession[]);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -115,7 +115,7 @@ export function QueueExecuteInSession({ item, className }: { item: QueueItem; cl
|
||||
useEffect(() => {
|
||||
void refreshSessions();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSessionKey) return;
|
||||
@@ -130,7 +130,7 @@ export function QueueExecuteInSession({ item, className }: { item: QueueItem; cl
|
||||
workingDir: projectPath,
|
||||
preferredShell: 'bash',
|
||||
resumeKey: item.issue_id,
|
||||
});
|
||||
}, projectPath);
|
||||
upsertSession(created.session as unknown as CliSession);
|
||||
setSelectedSessionKey(created.session.sessionKey);
|
||||
return created.session.sessionKey;
|
||||
@@ -144,7 +144,7 @@ export function QueueExecuteInSession({ item, className }: { item: QueueItem; cl
|
||||
workingDir: projectPath,
|
||||
preferredShell: 'bash',
|
||||
resumeKey: item.issue_id,
|
||||
});
|
||||
}, projectPath);
|
||||
upsertSession(created.session as unknown as CliSession);
|
||||
setSelectedSessionKey(created.session.sessionKey);
|
||||
await refreshSessions();
|
||||
@@ -168,7 +168,7 @@ export function QueueExecuteInSession({ item, className }: { item: QueueItem; cl
|
||||
category: 'user',
|
||||
resumeKey: item.issue_id,
|
||||
resumeStrategy,
|
||||
});
|
||||
}, projectPath);
|
||||
setLastExecution({ executionId: result.executionId, command: result.command });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
|
||||
@@ -5707,28 +5707,41 @@ export interface CreateCliSessionInput {
|
||||
resumeKey?: string;
|
||||
}
|
||||
|
||||
export async function fetchCliSessions(): Promise<{ sessions: CliSession[] }> {
|
||||
return fetchApi<{ sessions: CliSession[] }>('/api/cli-sessions');
|
||||
function withPath(url: string, projectPath?: string): string {
|
||||
if (!projectPath) return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}path=${encodeURIComponent(projectPath)}`;
|
||||
}
|
||||
|
||||
export async function createCliSession(input: CreateCliSessionInput): Promise<{ success: boolean; session: CliSession }> {
|
||||
return fetchApi<{ success: boolean; session: CliSession }>('/api/cli-sessions', {
|
||||
export async function fetchCliSessions(projectPath?: string): Promise<{ sessions: CliSession[] }> {
|
||||
return fetchApi<{ sessions: CliSession[] }>(withPath('/api/cli-sessions', projectPath));
|
||||
}
|
||||
|
||||
export async function createCliSession(
|
||||
input: CreateCliSessionInput,
|
||||
projectPath?: string
|
||||
): Promise<{ success: boolean; session: CliSession }> {
|
||||
return fetchApi<{ success: boolean; session: CliSession }>(withPath('/api/cli-sessions', projectPath), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCliSessionBuffer(sessionKey: string): Promise<{ session: CliSession; buffer: string }> {
|
||||
export async function fetchCliSessionBuffer(
|
||||
sessionKey: string,
|
||||
projectPath?: string
|
||||
): Promise<{ session: CliSession; buffer: string }> {
|
||||
return fetchApi<{ session: CliSession; buffer: string }>(
|
||||
`/api/cli-sessions/${encodeURIComponent(sessionKey)}/buffer`
|
||||
withPath(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/buffer`, projectPath)
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendCliSessionText(
|
||||
sessionKey: string,
|
||||
input: { text: string; appendNewline?: boolean }
|
||||
input: { text: string; appendNewline?: boolean },
|
||||
projectPath?: string
|
||||
): Promise<{ success: boolean }> {
|
||||
return fetchApi<{ success: boolean }>(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/send`, {
|
||||
return fetchApi<{ success: boolean }>(withPath(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/send`, projectPath), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
@@ -5747,27 +5760,40 @@ export interface ExecuteInCliSessionInput {
|
||||
|
||||
export async function executeInCliSession(
|
||||
sessionKey: string,
|
||||
input: ExecuteInCliSessionInput
|
||||
input: ExecuteInCliSessionInput,
|
||||
projectPath?: string
|
||||
): Promise<{ success: boolean; executionId: string; command: string }> {
|
||||
return fetchApi<{ success: boolean; executionId: string; command: string }>(
|
||||
`/api/cli-sessions/${encodeURIComponent(sessionKey)}/execute`,
|
||||
withPath(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/execute`, projectPath),
|
||||
{ method: 'POST', body: JSON.stringify(input) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function resizeCliSession(
|
||||
sessionKey: string,
|
||||
input: { cols: number; rows: number }
|
||||
input: { cols: number; rows: number },
|
||||
projectPath?: string
|
||||
): Promise<{ success: boolean }> {
|
||||
return fetchApi<{ success: boolean }>(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/resize`, {
|
||||
return fetchApi<{ success: boolean }>(withPath(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/resize`, projectPath), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeCliSession(sessionKey: string): Promise<{ success: boolean }> {
|
||||
return fetchApi<{ success: boolean }>(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/close`, {
|
||||
export async function closeCliSession(sessionKey: string, projectPath?: string): Promise<{ success: boolean }> {
|
||||
return fetchApi<{ success: boolean }>(withPath(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/close`, projectPath), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createCliSessionShareToken(
|
||||
sessionKey: string,
|
||||
input: { mode?: 'read' | 'write'; ttlMs?: number },
|
||||
projectPath?: string
|
||||
): Promise<{ success: boolean; shareToken: string; expiresAt: string; mode: 'read' | 'write' }> {
|
||||
return fetchApi<{ success: boolean; shareToken: string; expiresAt: string; mode: 'read' | 'write' }>(
|
||||
withPath(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/share`, projectPath),
|
||||
{ method: 'POST', body: JSON.stringify(input) }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,7 +119,8 @@
|
||||
"none": "No sessions",
|
||||
"refresh": "Refresh",
|
||||
"new": "New Session",
|
||||
"close": "Close"
|
||||
"close": "Close",
|
||||
"share": "Share (Read-only)"
|
||||
},
|
||||
"exec": {
|
||||
"tool": "Tool",
|
||||
|
||||
@@ -119,7 +119,8 @@
|
||||
"none": "暂无会话",
|
||||
"refresh": "刷新",
|
||||
"new": "新建会话",
|
||||
"close": "关闭"
|
||||
"close": "关闭",
|
||||
"share": "分享(只读)"
|
||||
},
|
||||
"exec": {
|
||||
"tool": "工具",
|
||||
|
||||
@@ -14,10 +14,52 @@
|
||||
|
||||
import type { RouteContext } from './types.js';
|
||||
import { getCliSessionManager } from '../services/cli-session-manager.js';
|
||||
import path from 'path';
|
||||
import { getCliSessionPolicy } from '../services/cli-session-policy.js';
|
||||
import { RateLimiter } from '../services/rate-limiter.js';
|
||||
import { appendCliSessionAudit } from '../services/cli-session-audit.js';
|
||||
import { describeShareAuthFailure, getCliSessionShareManager } from '../services/cli-session-share.js';
|
||||
|
||||
function clientKey(req: RouteContext['req']): string {
|
||||
const addr = req.socket?.remoteAddress ?? 'unknown';
|
||||
const ua = Array.isArray(req.headers['user-agent']) ? req.headers['user-agent'][0] : req.headers['user-agent'];
|
||||
return `${addr}|${ua ?? ''}`;
|
||||
}
|
||||
|
||||
function clientInfo(req: RouteContext['req']): { ip?: string; userAgent?: string } {
|
||||
const ip = req.socket?.remoteAddress ?? undefined;
|
||||
const userAgent = Array.isArray(req.headers['user-agent']) ? req.headers['user-agent'][0] : req.headers['user-agent'];
|
||||
return { ip: ip || undefined, userAgent: userAgent || undefined };
|
||||
}
|
||||
|
||||
function resolveProjectRoot(ctx: RouteContext): string {
|
||||
const forced = (ctx.req as any).__cliSessionShareProjectRoot;
|
||||
if (typeof forced === 'string' && forced.trim()) return path.resolve(forced);
|
||||
const raw = ctx.url.searchParams.get('path');
|
||||
if (raw && raw.trim()) return path.resolve(raw);
|
||||
return path.resolve(ctx.initialPath || process.cwd());
|
||||
}
|
||||
|
||||
function validateWorkingDir(projectRoot: string, workingDir: string, allowOutside: boolean): string | null {
|
||||
const resolved = path.resolve(workingDir);
|
||||
if (allowOutside) return null;
|
||||
|
||||
const rel = path.relative(projectRoot, resolved);
|
||||
const isInside = rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
||||
return isInside ? null : `workingDir must be within project: ${projectRoot}`;
|
||||
}
|
||||
|
||||
const policy = getCliSessionPolicy();
|
||||
const createLimiter = new RateLimiter({ limit: policy.rateLimit.createPerMinute, windowMs: 60_000 });
|
||||
const executeLimiter = new RateLimiter({ limit: policy.rateLimit.executePerMinute, windowMs: 60_000 });
|
||||
const resizeLimiter = new RateLimiter({ limit: policy.rateLimit.resizePerMinute, windowMs: 60_000 });
|
||||
const sendBytesLimiter = new RateLimiter({ limit: policy.rateLimit.sendBytesPerMinute, windowMs: 60_000 });
|
||||
const shareManager = getCliSessionShareManager();
|
||||
|
||||
export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, req, res, handlePostRequest, initialPath } = ctx;
|
||||
const manager = getCliSessionManager(process.cwd());
|
||||
const projectRoot = resolveProjectRoot(ctx);
|
||||
const manager = getCliSessionManager(projectRoot);
|
||||
|
||||
// GET /api/cli-sessions
|
||||
if (pathname === '/api/cli-sessions' && req.method === 'GET') {
|
||||
@@ -29,6 +71,15 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
// POST /api/cli-sessions
|
||||
if (pathname === '/api/cli-sessions' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
const rate = createLimiter.consume(clientKey(req), 1);
|
||||
if (!rate.ok) {
|
||||
return { error: 'Rate limited', status: 429 };
|
||||
}
|
||||
|
||||
if (policy.maxSessions > 0 && manager.listSessions().length >= policy.maxSessions) {
|
||||
return { error: `Too many sessions (max ${policy.maxSessions})`, status: 429 };
|
||||
}
|
||||
|
||||
const {
|
||||
workingDir,
|
||||
cols,
|
||||
@@ -39,16 +90,41 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
resumeKey
|
||||
} = (body || {}) as any;
|
||||
|
||||
if (tool && typeof tool === 'string') {
|
||||
const normalizedTool = tool.trim();
|
||||
if (!policy.allowedTools.includes(normalizedTool)) {
|
||||
return { error: `Tool not allowed: ${normalizedTool}`, status: 400 };
|
||||
}
|
||||
}
|
||||
|
||||
const desiredWorkingDir = workingDir || initialPath;
|
||||
if (typeof desiredWorkingDir !== 'string' || !desiredWorkingDir.trim()) {
|
||||
return { error: 'workingDir is required', status: 400 };
|
||||
}
|
||||
const wdError = validateWorkingDir(projectRoot, desiredWorkingDir, policy.allowWorkingDirOutsideProject);
|
||||
if (wdError) return { error: wdError, status: 400 };
|
||||
|
||||
const session = manager.createSession({
|
||||
workingDir: workingDir || initialPath,
|
||||
workingDir: desiredWorkingDir,
|
||||
cols: typeof cols === 'number' ? cols : undefined,
|
||||
rows: typeof rows === 'number' ? rows : undefined,
|
||||
preferredShell: preferredShell === 'pwsh' ? 'pwsh' : 'bash',
|
||||
tool,
|
||||
tool: typeof tool === 'string' ? tool.trim() : undefined,
|
||||
model,
|
||||
resumeKey
|
||||
});
|
||||
|
||||
appendCliSessionAudit({
|
||||
type: 'session_created',
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
sessionKey: session.sessionKey,
|
||||
tool: session.tool,
|
||||
resumeKey: session.resumeKey,
|
||||
workingDir: session.workingDir,
|
||||
...clientInfo(req),
|
||||
});
|
||||
|
||||
return { success: true, session };
|
||||
});
|
||||
return true;
|
||||
@@ -58,6 +134,17 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
const bufferMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/buffer$/);
|
||||
if (bufferMatch && req.method === 'GET') {
|
||||
const sessionKey = decodeURIComponent(bufferMatch[1]);
|
||||
|
||||
const shareToken = ctx.url.searchParams.get('shareToken');
|
||||
if (shareToken) {
|
||||
const validated = shareManager.validateToken(shareToken, sessionKey);
|
||||
if (!validated || (validated.mode !== 'read' && validated.mode !== 'write')) {
|
||||
res.writeHead(403, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: describeShareAuthFailure().error }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const session = manager.getSession(sessionKey);
|
||||
if (!session) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
@@ -69,6 +156,59 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/cli-sessions/:sessionKey/stream (SSE)
|
||||
const streamMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/stream$/);
|
||||
if (streamMatch && req.method === 'GET') {
|
||||
const sessionKey = decodeURIComponent(streamMatch[1]);
|
||||
const shareToken = ctx.url.searchParams.get('shareToken');
|
||||
if (!shareToken) {
|
||||
res.writeHead(403, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'shareToken is required' }));
|
||||
return true;
|
||||
}
|
||||
const validated = shareManager.validateToken(shareToken, sessionKey);
|
||||
if (!validated || (validated.mode !== 'read' && validated.mode !== 'write')) {
|
||||
res.writeHead(403, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: describeShareAuthFailure().error }));
|
||||
return true;
|
||||
}
|
||||
|
||||
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': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
|
||||
const includeBuffer = ctx.url.searchParams.get('includeBuffer') !== '0';
|
||||
if (includeBuffer) {
|
||||
const buffer = manager.getBuffer(sessionKey);
|
||||
res.write(`event: buffer\ndata: ${JSON.stringify({ sessionKey, buffer })}\n\n`);
|
||||
}
|
||||
|
||||
const unsubscribe = manager.onOutput((event) => {
|
||||
if (event.sessionKey !== sessionKey) return;
|
||||
res.write(`event: output\ndata: ${JSON.stringify(event)}\n\n`);
|
||||
});
|
||||
|
||||
req.on('close', () => {
|
||||
unsubscribe();
|
||||
try {
|
||||
res.end();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/cli-sessions/:sessionKey/send
|
||||
const sendMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/send$/);
|
||||
if (sendMatch && req.method === 'POST') {
|
||||
@@ -78,17 +218,69 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
if (typeof text !== 'string') {
|
||||
return { error: 'text is required', status: 400 };
|
||||
}
|
||||
|
||||
const cost = Buffer.byteLength(text, 'utf8');
|
||||
const rate = sendBytesLimiter.consume(clientKey(req), cost);
|
||||
if (!rate.ok) {
|
||||
return { error: 'Rate limited', status: 429 };
|
||||
}
|
||||
|
||||
manager.sendText(sessionKey, text, appendNewline !== false);
|
||||
appendCliSessionAudit({
|
||||
type: 'session_send',
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
sessionKey,
|
||||
...clientInfo(req),
|
||||
details: { bytes: cost, appendNewline: appendNewline !== false },
|
||||
});
|
||||
return { success: true };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/cli-sessions/:sessionKey/share
|
||||
const shareMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/share$/);
|
||||
if (shareMatch && req.method === 'POST') {
|
||||
const sessionKey = decodeURIComponent(shareMatch[1]);
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
const { mode, ttlMs } = (body || {}) as any;
|
||||
const session = manager.getSession(sessionKey);
|
||||
if (!session) return { error: 'Session not found', status: 404 };
|
||||
|
||||
const shareMode = mode === 'write' ? 'write' : 'read';
|
||||
const safeTtlMs = typeof ttlMs === 'number' ? Math.min(Math.max(60_000, ttlMs), 7 * 24 * 60 * 60_000) : undefined;
|
||||
const token = shareManager.createToken({
|
||||
sessionKey,
|
||||
projectRoot,
|
||||
mode: shareMode,
|
||||
ttlMs: safeTtlMs,
|
||||
});
|
||||
|
||||
appendCliSessionAudit({
|
||||
type: 'session_share_created',
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
sessionKey,
|
||||
...clientInfo(req),
|
||||
details: { shareMode, expiresAt: token.expiresAt },
|
||||
});
|
||||
|
||||
return { success: true, shareToken: token.token, expiresAt: token.expiresAt, mode: token.mode };
|
||||
});
|
||||
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 rate = executeLimiter.consume(clientKey(req), 1);
|
||||
if (!rate.ok) {
|
||||
return { error: 'Rate limited', status: 429 };
|
||||
}
|
||||
|
||||
const {
|
||||
tool,
|
||||
prompt,
|
||||
@@ -106,9 +298,18 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
if (!prompt || typeof prompt !== 'string') {
|
||||
return { error: 'prompt is required', status: 400 };
|
||||
}
|
||||
const normalizedTool = tool.trim();
|
||||
if (!policy.allowedTools.includes(normalizedTool)) {
|
||||
return { error: `Tool not allowed: ${normalizedTool}`, status: 400 };
|
||||
}
|
||||
|
||||
if (workingDir && typeof workingDir === 'string') {
|
||||
const wdError = validateWorkingDir(projectRoot, workingDir, policy.allowWorkingDirOutsideProject);
|
||||
if (wdError) return { error: wdError, status: 400 };
|
||||
}
|
||||
|
||||
const result = manager.execute(sessionKey, {
|
||||
tool,
|
||||
tool: normalizedTool,
|
||||
prompt,
|
||||
mode,
|
||||
model,
|
||||
@@ -118,6 +319,18 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
resumeStrategy: resumeStrategy === 'promptConcat' ? 'promptConcat' : 'nativeResume'
|
||||
});
|
||||
|
||||
appendCliSessionAudit({
|
||||
type: 'session_execute',
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
sessionKey,
|
||||
tool: normalizedTool,
|
||||
resumeKey: typeof resumeKey === 'string' ? resumeKey : undefined,
|
||||
workingDir: typeof workingDir === 'string' ? workingDir : undefined,
|
||||
...clientInfo(req),
|
||||
details: { executionId: result.executionId, mode, category, resumeStrategy },
|
||||
});
|
||||
|
||||
return { success: true, ...result };
|
||||
});
|
||||
return true;
|
||||
@@ -128,11 +341,23 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
if (resizeMatch && req.method === 'POST') {
|
||||
const sessionKey = decodeURIComponent(resizeMatch[1]);
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
const rate = resizeLimiter.consume(clientKey(req), 1);
|
||||
if (!rate.ok) {
|
||||
return { error: 'Rate limited', status: 429 };
|
||||
}
|
||||
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);
|
||||
appendCliSessionAudit({
|
||||
type: 'session_resize',
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
sessionKey,
|
||||
...clientInfo(req),
|
||||
details: { cols, rows },
|
||||
});
|
||||
return { success: true };
|
||||
});
|
||||
return true;
|
||||
@@ -143,6 +368,13 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
if (closeMatch && req.method === 'POST') {
|
||||
const sessionKey = decodeURIComponent(closeMatch[1]);
|
||||
manager.close(sessionKey);
|
||||
appendCliSessionAudit({
|
||||
type: 'session_closed',
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
sessionKey,
|
||||
...clientInfo(req),
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
return true;
|
||||
@@ -150,4 +382,3 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ import { randomBytes } from 'crypto';
|
||||
|
||||
// Import health check service
|
||||
import { getHealthCheckService } from './services/health-check-service.js';
|
||||
import { getCliSessionShareManager } from './services/cli-session-share.js';
|
||||
|
||||
// Import status check functions for warmup
|
||||
import { checkSemanticStatus, checkVenvStatus } from '../tools/codex-lens.js';
|
||||
@@ -465,6 +466,7 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
const secretKey = tokenManager.getSecretKey();
|
||||
tokenManager.getOrCreateAuthToken();
|
||||
const unauthenticatedPaths = new Set<string>(['/api/auth/token', '/api/csrf-token', '/api/hook', '/api/test/ask-question', '/api/a2ui/answer']);
|
||||
const cliSessionShareManager = getCliSessionShareManager();
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://localhost:${serverPort}`);
|
||||
@@ -521,8 +523,24 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
|
||||
// Authentication middleware for all API routes
|
||||
if (pathname.startsWith('/api/')) {
|
||||
const ok = authMiddleware({ pathname, req, res, tokenManager, secretKey, unauthenticatedPaths });
|
||||
if (!ok) return;
|
||||
let shareBypass = false;
|
||||
const shareToken = url.searchParams.get('shareToken');
|
||||
if (shareToken) {
|
||||
const match = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/(buffer|stream)$/);
|
||||
if (match?.[1]) {
|
||||
const sessionKey = decodeURIComponent(match[1]);
|
||||
const validated = cliSessionShareManager.validateToken(shareToken, sessionKey);
|
||||
if (validated && (validated.mode === 'read' || validated.mode === 'write')) {
|
||||
(req as any).__cliSessionShareProjectRoot = validated.projectRoot;
|
||||
shareBypass = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shareBypass) {
|
||||
const ok = authMiddleware({ pathname, req, res, tokenManager, secretKey, unauthenticatedPaths });
|
||||
if (!ok) return;
|
||||
}
|
||||
}
|
||||
|
||||
// CSRF validation middleware for state-changing API routes
|
||||
|
||||
39
ccw/src/core/services/cli-session-audit.ts
Normal file
39
ccw/src/core/services/cli-session-audit.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { existsSync, mkdirSync, appendFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export type CliSessionAuditEventType =
|
||||
| 'session_created'
|
||||
| 'session_closed'
|
||||
| 'session_send'
|
||||
| 'session_execute'
|
||||
| 'session_resize'
|
||||
| 'session_share_created'
|
||||
| 'session_idle_reaped';
|
||||
|
||||
export interface CliSessionAuditEvent {
|
||||
type: CliSessionAuditEventType;
|
||||
timestamp: string;
|
||||
projectRoot: string;
|
||||
sessionKey?: string;
|
||||
tool?: string;
|
||||
resumeKey?: string;
|
||||
workingDir?: string;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function auditFilePath(projectRoot: string): string {
|
||||
return path.join(projectRoot, '.workflow', 'audit', 'cli-sessions.jsonl');
|
||||
}
|
||||
|
||||
export function appendCliSessionAudit(event: CliSessionAuditEvent): void {
|
||||
try {
|
||||
const filePath = auditFilePath(event.projectRoot);
|
||||
const dir = path.dirname(filePath);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(filePath, JSON.stringify(event) + '\n', { encoding: 'utf8' });
|
||||
} catch {
|
||||
// Best-effort: never fail API requests due to audit write errors.
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type CliSessionResumeStrategy
|
||||
} from './cli-session-command-builder.js';
|
||||
import { getCliSessionPolicy } from './cli-session-policy.js';
|
||||
import { appendCliSessionAudit } from './cli-session-audit.js';
|
||||
|
||||
export interface CliSession {
|
||||
sessionKey: string;
|
||||
@@ -147,10 +148,29 @@ export class CliSessionManager {
|
||||
private projectRoot: string;
|
||||
private emitter = new EventEmitter();
|
||||
private maxBufferBytes: number;
|
||||
private idleTimeoutMs: number;
|
||||
private reaperTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(projectRoot: string) {
|
||||
this.projectRoot = projectRoot;
|
||||
this.maxBufferBytes = getCliSessionPolicy().maxBufferBytes;
|
||||
const policy = getCliSessionPolicy();
|
||||
this.maxBufferBytes = policy.maxBufferBytes;
|
||||
this.idleTimeoutMs = policy.idleTimeoutMs;
|
||||
|
||||
if (this.idleTimeoutMs > 0) {
|
||||
this.reaperTimer = setInterval(() => {
|
||||
const reaped = this.closeIdleSessions(this.idleTimeoutMs);
|
||||
for (const sessionKey of reaped) {
|
||||
appendCliSessionAudit({
|
||||
type: 'session_idle_reaped',
|
||||
timestamp: nowIso(),
|
||||
projectRoot: this.projectRoot,
|
||||
sessionKey,
|
||||
});
|
||||
}
|
||||
}, 60_000);
|
||||
this.reaperTimer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
listSessions(): CliSession[] {
|
||||
@@ -354,14 +374,14 @@ export class CliSessionManager {
|
||||
return () => this.emitter.off('output', handler);
|
||||
}
|
||||
|
||||
closeIdleSessions(idleTimeoutMs: number): number {
|
||||
if (idleTimeoutMs <= 0) return 0;
|
||||
closeIdleSessions(idleTimeoutMs: number): string[] {
|
||||
if (idleTimeoutMs <= 0) return [];
|
||||
const now = Date.now();
|
||||
let closed = 0;
|
||||
const closed: string[] = [];
|
||||
for (const s of this.sessions.values()) {
|
||||
if (now - s.lastActivityAt >= idleTimeoutMs) {
|
||||
this.close(s.sessionKey);
|
||||
closed += 1;
|
||||
closed.push(s.sessionKey);
|
||||
}
|
||||
}
|
||||
return closed;
|
||||
|
||||
83
ccw/src/core/services/cli-session-share.ts
Normal file
83
ccw/src/core/services/cli-session-share.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export type CliSessionShareMode = 'read' | 'write';
|
||||
|
||||
export interface CliSessionShareTokenRecord {
|
||||
token: string;
|
||||
sessionKey: string;
|
||||
projectRoot: string;
|
||||
mode: CliSessionShareMode;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface InternalTokenRecord extends CliSessionShareTokenRecord {
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
function createTokenValue(): string {
|
||||
// 32 bytes => 43 chars base64url (approx), safe for URLs.
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
export class CliSessionShareManager {
|
||||
private tokens = new Map<string, InternalTokenRecord>();
|
||||
|
||||
createToken(input: {
|
||||
sessionKey: string;
|
||||
projectRoot: string;
|
||||
mode: CliSessionShareMode;
|
||||
ttlMs?: number;
|
||||
}): CliSessionShareTokenRecord {
|
||||
const ttlMs = typeof input.ttlMs === 'number' ? Math.max(1_000, input.ttlMs) : 24 * 60 * 60_000;
|
||||
const expiresAtMs = Date.now() + ttlMs;
|
||||
const record: InternalTokenRecord = {
|
||||
token: createTokenValue(),
|
||||
sessionKey: input.sessionKey,
|
||||
projectRoot: input.projectRoot,
|
||||
mode: input.mode,
|
||||
expiresAt: new Date(expiresAtMs).toISOString(),
|
||||
expiresAtMs,
|
||||
};
|
||||
this.tokens.set(record.token, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
validateToken(token: string, sessionKey: string): CliSessionShareTokenRecord | null {
|
||||
const record = this.tokens.get(token);
|
||||
if (!record) return null;
|
||||
if (record.sessionKey !== sessionKey) return null;
|
||||
if (Date.now() >= record.expiresAtMs) {
|
||||
this.tokens.delete(token);
|
||||
return null;
|
||||
}
|
||||
const { expiresAtMs: _expiresAtMs, ...publicRecord } = record;
|
||||
return publicRecord;
|
||||
}
|
||||
|
||||
revokeToken(token: string): boolean {
|
||||
return this.tokens.delete(token);
|
||||
}
|
||||
|
||||
cleanupExpired(): number {
|
||||
const now = Date.now();
|
||||
let removed = 0;
|
||||
for (const [token, record] of this.tokens) {
|
||||
if (now >= record.expiresAtMs) {
|
||||
this.tokens.delete(token);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: CliSessionShareManager | null = null;
|
||||
|
||||
export function getCliSessionShareManager(): CliSessionShareManager {
|
||||
if (!singleton) singleton = new CliSessionShareManager();
|
||||
return singleton;
|
||||
}
|
||||
|
||||
export function describeShareAuthFailure(): { error: string; status: number } {
|
||||
return { error: 'Invalid or expired share token', status: 403 };
|
||||
}
|
||||
@@ -255,7 +255,7 @@ export class NodeRunner {
|
||||
};
|
||||
}
|
||||
|
||||
const manager = getCliSessionManager(process.cwd());
|
||||
const manager = getCliSessionManager(this.context.workingDir || process.cwd());
|
||||
const routed = manager.execute(targetSessionKey, {
|
||||
tool,
|
||||
prompt: instruction,
|
||||
|
||||
@@ -22,7 +22,7 @@ const ENV_PROJECT_ROOT = 'CCW_PROJECT_ROOT';
|
||||
const ENV_ALLOWED_DIRS = 'CCW_ALLOWED_DIRS';
|
||||
|
||||
// Default enabled tools (core set - file operations, core memory, and smart search)
|
||||
const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'read_many_files', 'core_memory', 'smart_search'];
|
||||
const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'read_many_files', 'read_outline', 'core_memory', 'smart_search'];
|
||||
|
||||
/**
|
||||
* Get list of enabled tools from environment or defaults
|
||||
|
||||
@@ -24,6 +24,7 @@ import * as codexLensLspMod from './codex-lens-lsp.js';
|
||||
import * as vscodeLspMod from './vscode-lsp.js';
|
||||
import * as readFileMod from './read-file.js';
|
||||
import * as readManyFilesMod from './read-many-files.js';
|
||||
import * as readOutlineMod from './read-outline.js';
|
||||
import * as coreMemoryMod from './core-memory.js';
|
||||
import * as contextCacheMod from './context-cache.js';
|
||||
import * as skillContextLoaderMod from './skill-context-loader.js';
|
||||
@@ -367,6 +368,7 @@ registerTool(toLegacyTool(codexLensLspMod));
|
||||
registerTool(toLegacyTool(vscodeLspMod));
|
||||
registerTool(toLegacyTool(readFileMod));
|
||||
registerTool(toLegacyTool(readManyFilesMod));
|
||||
registerTool(toLegacyTool(readOutlineMod));
|
||||
registerTool(toLegacyTool(coreMemoryMod));
|
||||
registerTool(toLegacyTool(contextCacheMod));
|
||||
registerTool(toLegacyTool(skillContextLoaderMod));
|
||||
|
||||
104
ccw/src/tools/read-outline.ts
Normal file
104
ccw/src/tools/read-outline.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Read Outline Tool - Parse code files into structured symbol outlines.
|
||||
*
|
||||
* Uses web-tree-sitter for AST-level parsing. Returns function/class/method
|
||||
* signatures with line offsets directly usable by read_file(offset, limit).
|
||||
*
|
||||
* Supported: TypeScript, TSX, JavaScript, Python, Go, Rust, Java, C#, C, C++
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
||||
import { existsSync, statSync, readFileSync } from 'fs';
|
||||
import { relative } from 'path';
|
||||
import { validatePath, getProjectRoot } from '../utils/path-validator.js';
|
||||
import { BINARY_EXTENSIONS } from '../utils/file-reader.js';
|
||||
import { detectLanguage } from '../utils/outline-queries.js';
|
||||
import { parseOutline } from '../utils/outline-parser.js';
|
||||
import type { OutlineResult } from '../utils/outline-parser.js';
|
||||
import { extname } from 'path';
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
path: z.string().describe('File path to parse for outline'),
|
||||
language: z.string().optional().describe('Language hint (e.g. "typescript", "python"). Auto-detected from extension if omitted.'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof ParamsSchema>;
|
||||
|
||||
export const schema: ToolSchema = {
|
||||
name: 'read_outline',
|
||||
description: `Parse a code file into a structured outline of symbols (functions, classes, methods, interfaces, types, enums).
|
||||
|
||||
Returns symbol names, signatures, docstrings, and 0-based line offsets that work directly with read_file(offset, limit).
|
||||
|
||||
Usage:
|
||||
read_outline(path="src/server.ts")
|
||||
read_outline(path="main.py", language="python")
|
||||
|
||||
Workflow: discover symbols → use line/endLine with read_file to jump to implementations.
|
||||
|
||||
Supported languages: TypeScript, TSX, JavaScript, Python, Go, Rust, Java, C#, C, C++`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'File path to parse for outline' },
|
||||
language: { type: 'string', description: 'Language hint (e.g. "typescript", "python"). Auto-detected from extension if omitted.' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
};
|
||||
|
||||
export async function handler(params: Record<string, unknown>): Promise<ToolResult<OutlineResult>> {
|
||||
const parsed = ParamsSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: `Invalid params: ${parsed.error.message}` };
|
||||
}
|
||||
|
||||
const { path: filePath, language: langHint } = parsed.data;
|
||||
const cwd = getProjectRoot();
|
||||
const resolvedPath = await validatePath(filePath);
|
||||
|
||||
if (!existsSync(resolvedPath)) {
|
||||
return { success: false, error: `File not found: ${filePath}` };
|
||||
}
|
||||
|
||||
const stat = statSync(resolvedPath);
|
||||
if (!stat.isFile()) {
|
||||
return { success: false, error: `Not a file: ${filePath}` };
|
||||
}
|
||||
|
||||
// Check for binary files
|
||||
const ext = extname(resolvedPath).toLowerCase();
|
||||
if (BINARY_EXTENSIONS.has(ext)) {
|
||||
return { success: false, error: `Binary file not supported: ${filePath}` };
|
||||
}
|
||||
|
||||
// Detect language
|
||||
const config = detectLanguage(resolvedPath, langHint);
|
||||
if (!config) {
|
||||
const supported = 'TypeScript, TSX, JavaScript, Python, Go, Rust, Java, C#, C, C++';
|
||||
return {
|
||||
success: false,
|
||||
error: `Unsupported language for "${ext}" extension. Supported: ${supported}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const content = readFileSync(resolvedPath, 'utf-8');
|
||||
|
||||
// Parse outline
|
||||
try {
|
||||
const result = await parseOutline(
|
||||
relative(cwd, resolvedPath) || filePath,
|
||||
content,
|
||||
config
|
||||
);
|
||||
|
||||
return { success: true, result };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Outline parsing failed: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
340
ccw/src/utils/outline-parser.ts
Normal file
340
ccw/src/utils/outline-parser.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Core AST outline parsing engine using web-tree-sitter.
|
||||
*
|
||||
* Parses source files into structured symbol outlines (functions, classes, methods, etc.)
|
||||
* with line offsets compatible with read_file(offset, limit).
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'path';
|
||||
import Parser from 'web-tree-sitter';
|
||||
import type { LanguageConfig } from './outline-queries.js';
|
||||
|
||||
export interface OutlineSymbol {
|
||||
kind: 'function' | 'class' | 'method' | 'interface' | 'type' | 'enum' | 'property';
|
||||
name: string;
|
||||
line: number; // 0-based, compatible with read_file offset
|
||||
endLine: number; // 0-based
|
||||
doc: string | null;
|
||||
signature: string; // truncated to 200 chars
|
||||
parent: string | null;
|
||||
children: number; // nested method/property count (class/interface)
|
||||
}
|
||||
|
||||
export interface OutlineResult {
|
||||
file: string;
|
||||
language: string;
|
||||
symbols: OutlineSymbol[];
|
||||
totalSymbols: number;
|
||||
}
|
||||
|
||||
// Singleton init guard
|
||||
let initialized = false;
|
||||
|
||||
// Language WASM cache (Language loading is heavy IO, cache aggressively)
|
||||
const languageCache = new Map<string, Parser.Language>();
|
||||
|
||||
// Resolve WASM paths via createRequire (works in ESM)
|
||||
const _require = createRequire(import.meta.url);
|
||||
|
||||
function getWasmDir(): string {
|
||||
return join(dirname(_require.resolve('tree-sitter-wasms/package.json')), 'out');
|
||||
}
|
||||
|
||||
async function ensureInit(): Promise<void> {
|
||||
if (initialized) return;
|
||||
await Parser.init();
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
async function loadLanguage(grammarName: string): Promise<Parser.Language> {
|
||||
const cached = languageCache.get(grammarName);
|
||||
if (cached) return cached;
|
||||
|
||||
const wasmPath = join(getWasmDir(), `tree-sitter-${grammarName}.wasm`);
|
||||
const lang = await Parser.Language.load(wasmPath);
|
||||
languageCache.set(grammarName, lang);
|
||||
return lang;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a source file into an outline of symbols.
|
||||
*/
|
||||
export async function parseOutline(
|
||||
filePath: string,
|
||||
content: string,
|
||||
config: LanguageConfig
|
||||
): Promise<OutlineResult> {
|
||||
await ensureInit();
|
||||
|
||||
const language = await loadLanguage(config.grammarName);
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(language);
|
||||
|
||||
const tree = parser.parse(content);
|
||||
if (!tree) {
|
||||
parser.delete();
|
||||
return { file: filePath, language: config.grammarName, symbols: [], totalSymbols: 0 };
|
||||
}
|
||||
|
||||
let query: Parser.Query;
|
||||
try {
|
||||
query = language.query(config.symbolQuery);
|
||||
} catch (err) {
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
throw new Error(`Query compilation failed for ${config.grammarName}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const matches = query.matches(tree.rootNode);
|
||||
const contentLines = content.split('\n');
|
||||
const symbols: OutlineSymbol[] = [];
|
||||
|
||||
for (const match of matches) {
|
||||
const symbol = processMatch(match, contentLines, config.grammarName);
|
||||
if (symbol) symbols.push(symbol);
|
||||
}
|
||||
|
||||
// Sort by line position
|
||||
symbols.sort((a, b) => a.line - b.line);
|
||||
|
||||
// Clean up native resources
|
||||
query.delete();
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
|
||||
return {
|
||||
file: filePath,
|
||||
language: config.grammarName,
|
||||
symbols,
|
||||
totalSymbols: symbols.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single query match into an OutlineSymbol.
|
||||
*/
|
||||
function processMatch(
|
||||
match: Parser.QueryMatch,
|
||||
contentLines: string[],
|
||||
language: string
|
||||
): OutlineSymbol | null {
|
||||
let nameNode: Parser.SyntaxNode | null = null;
|
||||
let defNode: Parser.SyntaxNode | null = null;
|
||||
let kind = 'function';
|
||||
|
||||
for (const capture of match.captures) {
|
||||
if (capture.name === 'name') {
|
||||
nameNode = capture.node;
|
||||
} else if (capture.name.startsWith('definition.')) {
|
||||
defNode = capture.node;
|
||||
kind = capture.name.slice('definition.'.length);
|
||||
}
|
||||
}
|
||||
|
||||
if (!defNode || !nameNode) return null;
|
||||
|
||||
const name = nameNode.text;
|
||||
const line = defNode.startPosition.row;
|
||||
const endLine = defNode.endPosition.row;
|
||||
const signature = extractSignature(defNode.text, language);
|
||||
const doc = extractDoc(defNode, contentLines, language);
|
||||
const parent = findParent(defNode);
|
||||
const children = countChildren(defNode, kind);
|
||||
|
||||
return {
|
||||
kind: kind as OutlineSymbol['kind'],
|
||||
name,
|
||||
line,
|
||||
endLine,
|
||||
doc,
|
||||
signature,
|
||||
parent,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a concise signature from the node text.
|
||||
* Takes the first line, removes the body start, truncates to 200 chars.
|
||||
*/
|
||||
function extractSignature(nodeText: string, language: string): string {
|
||||
const firstLine = nodeText.split('\n')[0].trimEnd();
|
||||
let sig = firstLine;
|
||||
|
||||
if (language === 'python') {
|
||||
// Remove trailing colon (body start)
|
||||
if (sig.endsWith(':')) {
|
||||
sig = sig.slice(0, -1).trimEnd();
|
||||
}
|
||||
} else {
|
||||
// Remove opening brace and everything after
|
||||
const braceIdx = sig.indexOf('{');
|
||||
if (braceIdx > 0) {
|
||||
sig = sig.substring(0, braceIdx).trimEnd();
|
||||
}
|
||||
}
|
||||
|
||||
if (sig.length > 200) {
|
||||
sig = sig.substring(0, 200) + '...';
|
||||
}
|
||||
|
||||
return sig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract documentation comment for a definition node.
|
||||
*/
|
||||
function extractDoc(
|
||||
defNode: Parser.SyntaxNode,
|
||||
contentLines: string[],
|
||||
language: string
|
||||
): string | null {
|
||||
if (language === 'python') {
|
||||
return extractPythonDocstring(defNode);
|
||||
}
|
||||
return extractCommentDoc(defNode, contentLines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract comment doc by looking at lines before the definition.
|
||||
*/
|
||||
function extractCommentDoc(
|
||||
defNode: Parser.SyntaxNode,
|
||||
contentLines: string[]
|
||||
): string | null {
|
||||
const defLine = defNode.startPosition.row;
|
||||
let endIdx = defLine - 1;
|
||||
if (endIdx < 0) return null;
|
||||
|
||||
// Skip at most one blank line
|
||||
if (contentLines[endIdx].trim() === '') {
|
||||
endIdx--;
|
||||
if (endIdx < 0) return null;
|
||||
}
|
||||
|
||||
const endText = contentLines[endIdx].trim();
|
||||
|
||||
// Block comment ending with */
|
||||
if (endText.endsWith('*/')) {
|
||||
let startIdx = endIdx;
|
||||
while (startIdx > 0 && !contentLines[startIdx].trim().startsWith('/*')) {
|
||||
startIdx--;
|
||||
}
|
||||
return cleanBlockComment(contentLines.slice(startIdx, endIdx + 1).join('\n'));
|
||||
}
|
||||
|
||||
// Line comments (// or /// or #)
|
||||
if (endText.startsWith('//') || endText.startsWith('#')) {
|
||||
let startIdx = endIdx;
|
||||
while (startIdx > 0) {
|
||||
const prevText = contentLines[startIdx - 1].trim();
|
||||
if (prevText.startsWith('//') || prevText.startsWith('#')) {
|
||||
startIdx--;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return cleanLineComments(contentLines.slice(startIdx, endIdx + 1).join('\n'));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Python docstring from function/class body.
|
||||
*/
|
||||
function extractPythonDocstring(defNode: Parser.SyntaxNode): string | null {
|
||||
const body = defNode.childForFieldName('body');
|
||||
if (!body) return null;
|
||||
|
||||
const firstChild = body.namedChildren[0];
|
||||
if (!firstChild || firstChild.type !== 'expression_statement') return null;
|
||||
|
||||
const expr = firstChild.namedChildren[0];
|
||||
if (!expr || (expr.type !== 'string' && expr.type !== 'concatenated_string')) return null;
|
||||
|
||||
let text = expr.text;
|
||||
// Remove triple-quote markers
|
||||
for (const quote of ['"""', "'''"]) {
|
||||
if (text.startsWith(quote) && text.endsWith(quote)) {
|
||||
text = text.slice(3, -3);
|
||||
break;
|
||||
}
|
||||
}
|
||||
text = text.trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean block comment text.
|
||||
*/
|
||||
function cleanBlockComment(text: string): string | null {
|
||||
let lines = text.split('\n');
|
||||
// Remove /* and */
|
||||
lines[0] = lines[0].replace(/^\s*\/\*\*?\s?/, '');
|
||||
lines[lines.length - 1] = lines[lines.length - 1].replace(/\s*\*\/\s*$/, '');
|
||||
// Remove leading * from middle lines
|
||||
lines = lines.map(l => l.replace(/^\s*\*\s?/, ''));
|
||||
const result = lines.join('\n').trim();
|
||||
return result || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean line comment (// or #) text.
|
||||
*/
|
||||
function cleanLineComments(text: string): string | null {
|
||||
const lines = text.split('\n').map(l => l.replace(/^\s*(?:\/\/\/?\s?|#\s?)/, ''));
|
||||
const result = lines.join('\n').trim();
|
||||
return result || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the parent class/interface/impl name for a definition node.
|
||||
*/
|
||||
function findParent(defNode: Parser.SyntaxNode): string | null {
|
||||
let current = defNode.parent;
|
||||
while (current) {
|
||||
const type = current.type;
|
||||
|
||||
// Common parent types across languages
|
||||
if (
|
||||
type === 'class_declaration' || type === 'interface_declaration' ||
|
||||
type === 'class_definition' || type === 'enum_declaration' ||
|
||||
type === 'impl_item' || type === 'class_specifier' || type === 'struct_specifier'
|
||||
) {
|
||||
// Try 'name' field first, then 'type' field (for Rust impl_item)
|
||||
const nameNode = current.childForFieldName('name') || current.childForFieldName('type');
|
||||
if (nameNode) return nameNode.text;
|
||||
}
|
||||
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count direct children (methods/properties) for class/interface nodes.
|
||||
*/
|
||||
function countChildren(defNode: Parser.SyntaxNode, kind: string): number {
|
||||
if (kind !== 'class' && kind !== 'interface') return 0;
|
||||
|
||||
// Find the body node (class_body, interface_body, block, declaration_list, etc.)
|
||||
let body = defNode.childForFieldName('body');
|
||||
if (!body) {
|
||||
for (const child of defNode.namedChildren) {
|
||||
if (
|
||||
child.type === 'class_body' || child.type === 'interface_body' ||
|
||||
child.type === 'declaration_list' || child.type === 'block' ||
|
||||
child.type === 'enum_body' || child.type === 'field_declaration_list'
|
||||
) {
|
||||
body = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!body) return 0;
|
||||
return body.namedChildCount;
|
||||
}
|
||||
150
ccw/src/utils/outline-queries.ts
Normal file
150
ccw/src/utils/outline-queries.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Language configurations and tree-sitter query definitions for outline parsing.
|
||||
*/
|
||||
|
||||
import { extname } from 'path';
|
||||
|
||||
export interface LanguageConfig {
|
||||
grammarName: string;
|
||||
extensions: string[];
|
||||
symbolQuery: string;
|
||||
}
|
||||
|
||||
export const LANGUAGE_CONFIGS: Record<string, LanguageConfig> = {
|
||||
typescript: {
|
||||
grammarName: 'typescript',
|
||||
extensions: ['.ts'],
|
||||
symbolQuery: [
|
||||
'(function_declaration name: (identifier) @name) @definition.function',
|
||||
'(class_declaration name: (type_identifier) @name) @definition.class',
|
||||
'(method_definition name: (property_identifier) @name) @definition.method',
|
||||
'(abstract_method_signature name: (property_identifier) @name) @definition.method',
|
||||
'(interface_declaration name: (type_identifier) @name) @definition.interface',
|
||||
'(type_alias_declaration name: (type_identifier) @name) @definition.type',
|
||||
'(enum_declaration name: (identifier) @name) @definition.enum',
|
||||
'(variable_declarator name: (identifier) @name value: (arrow_function)) @definition.function',
|
||||
'(variable_declarator name: (identifier) @name value: (function_expression)) @definition.function',
|
||||
].join('\n'),
|
||||
},
|
||||
tsx: {
|
||||
grammarName: 'tsx',
|
||||
extensions: ['.tsx'],
|
||||
symbolQuery: [
|
||||
'(function_declaration name: (identifier) @name) @definition.function',
|
||||
'(class_declaration name: (type_identifier) @name) @definition.class',
|
||||
'(method_definition name: (property_identifier) @name) @definition.method',
|
||||
'(interface_declaration name: (type_identifier) @name) @definition.interface',
|
||||
'(type_alias_declaration name: (type_identifier) @name) @definition.type',
|
||||
'(enum_declaration name: (identifier) @name) @definition.enum',
|
||||
'(variable_declarator name: (identifier) @name value: (arrow_function)) @definition.function',
|
||||
'(variable_declarator name: (identifier) @name value: (function_expression)) @definition.function',
|
||||
].join('\n'),
|
||||
},
|
||||
javascript: {
|
||||
grammarName: 'javascript',
|
||||
extensions: ['.js', '.jsx', '.mjs', '.cjs'],
|
||||
symbolQuery: [
|
||||
'(function_declaration name: (identifier) @name) @definition.function',
|
||||
'(class_declaration name: (identifier) @name) @definition.class',
|
||||
'(method_definition name: (property_identifier) @name) @definition.method',
|
||||
'(variable_declarator name: (identifier) @name value: (arrow_function)) @definition.function',
|
||||
'(variable_declarator name: (identifier) @name value: (function_expression)) @definition.function',
|
||||
].join('\n'),
|
||||
},
|
||||
python: {
|
||||
grammarName: 'python',
|
||||
extensions: ['.py'],
|
||||
symbolQuery: [
|
||||
'(function_definition name: (identifier) @name) @definition.function',
|
||||
'(class_definition name: (identifier) @name) @definition.class',
|
||||
].join('\n'),
|
||||
},
|
||||
go: {
|
||||
grammarName: 'go',
|
||||
extensions: ['.go'],
|
||||
symbolQuery: [
|
||||
'(function_declaration name: (identifier) @name) @definition.function',
|
||||
'(method_declaration name: (field_identifier) @name) @definition.method',
|
||||
'(type_spec name: (type_identifier) @name) @definition.type',
|
||||
].join('\n'),
|
||||
},
|
||||
rust: {
|
||||
grammarName: 'rust',
|
||||
extensions: ['.rs'],
|
||||
symbolQuery: [
|
||||
'(function_item name: (identifier) @name) @definition.function',
|
||||
'(struct_item name: (type_identifier) @name) @definition.class',
|
||||
'(enum_item name: (type_identifier) @name) @definition.enum',
|
||||
'(trait_item name: (type_identifier) @name) @definition.interface',
|
||||
'(impl_item type: (type_identifier) @name) @definition.class',
|
||||
].join('\n'),
|
||||
},
|
||||
java: {
|
||||
grammarName: 'java',
|
||||
extensions: ['.java'],
|
||||
symbolQuery: [
|
||||
'(class_declaration name: (identifier) @name) @definition.class',
|
||||
'(method_declaration name: (identifier) @name) @definition.method',
|
||||
'(interface_declaration name: (identifier) @name) @definition.interface',
|
||||
'(enum_declaration name: (identifier) @name) @definition.enum',
|
||||
'(constructor_declaration name: (identifier) @name) @definition.method',
|
||||
].join('\n'),
|
||||
},
|
||||
csharp: {
|
||||
grammarName: 'c_sharp',
|
||||
extensions: ['.cs'],
|
||||
symbolQuery: [
|
||||
'(class_declaration name: (identifier) @name) @definition.class',
|
||||
'(method_declaration name: (identifier) @name) @definition.method',
|
||||
'(interface_declaration name: (identifier) @name) @definition.interface',
|
||||
'(enum_declaration name: (identifier) @name) @definition.enum',
|
||||
'(constructor_declaration name: (identifier) @name) @definition.method',
|
||||
].join('\n'),
|
||||
},
|
||||
c: {
|
||||
grammarName: 'c',
|
||||
extensions: ['.c', '.h'],
|
||||
symbolQuery: [
|
||||
'(function_definition declarator: (function_declarator declarator: (identifier) @name)) @definition.function',
|
||||
'(struct_specifier name: (type_identifier) @name) @definition.class',
|
||||
'(enum_specifier name: (type_identifier) @name) @definition.enum',
|
||||
].join('\n'),
|
||||
},
|
||||
cpp: {
|
||||
grammarName: 'cpp',
|
||||
extensions: ['.cpp', '.hpp', '.cc', '.cxx'],
|
||||
symbolQuery: [
|
||||
'(function_definition declarator: (function_declarator declarator: (identifier) @name)) @definition.function',
|
||||
'(function_definition declarator: (function_declarator declarator: (qualified_identifier name: (identifier) @name))) @definition.function',
|
||||
'(class_specifier name: (type_identifier) @name) @definition.class',
|
||||
'(struct_specifier name: (type_identifier) @name) @definition.class',
|
||||
'(enum_specifier name: (type_identifier) @name) @definition.enum',
|
||||
].join('\n'),
|
||||
},
|
||||
};
|
||||
|
||||
// Build extension → language name lookup map
|
||||
const EXTENSION_MAP = new Map<string, string>();
|
||||
for (const [lang, config] of Object.entries(LANGUAGE_CONFIGS)) {
|
||||
for (const ext of config.extensions) {
|
||||
EXTENSION_MAP.set(ext, lang);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect language config from file path extension or explicit hint.
|
||||
* Returns null if language is not supported.
|
||||
*/
|
||||
export function detectLanguage(filePath: string, hint?: string): LanguageConfig | null {
|
||||
if (hint) {
|
||||
const normalized = hint.toLowerCase();
|
||||
const config = LANGUAGE_CONFIGS[normalized];
|
||||
if (config) return config;
|
||||
}
|
||||
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
const lang = EXTENSION_MAP.get(ext);
|
||||
if (lang) return LANGUAGE_CONFIGS[lang];
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user