Files
clawtap/server/adapters/claude/jsonl-store.ts
T
kuannnn 0fcf66fc22 feat: ClawTap v0.2.0
Interactive Prompts:
- Unified InteractivePrompt type across all 3 adapters (Claude/Codex/Gemini)
- InteractivePromptOverlay component with options, text input, countdown
- Gemini + Codex pane monitors detect tool confirmation, ask user, plan approval
- respondInteractivePrompt routing: permission → respondPermission, options → _selectOption
- Claude AskUserQuestion nested questions[0] structure parsing

Cross-AI Review:
- Client-generated reviewId, removed pendingReview state
- FloatingReviewPanel uses CSS display:none instead of unmount (keeps hooks alive)
- Child review sessions default to YOLO/bypass permission mode
- Send back to parent, send to existing/new review, tab switching, end review
- Collapsed review cards with read-only panel for ended reviews
- Full reconnect support: active + ended reviews restore correctly

AskUserQuestion Tool Card UI:
- Dedicated renderer replaces raw JSON display
- Options shown with selected (green) / unselected (gray) indicators
- Free text answers shown in quoted format with green border
- Collapsed summary: question → answer
- Shared parseAskQuestionInput utility (client + server)
- Historical tool results attached via _result on tool_use blocks

Adapter Fixes:
- Session→adapter mapping persisted in SQLite (survives server restart)
- SESSION_CREATED deferred for pendingRekey adapters (Codex/Gemini)
- session-rekeyed handler sends complete SESSION_CREATED with adapter + cwd
- Gemini: auto-accept folder trust, privacy notice, IDE nudge, YOLO * prompt
- Claude: auto-accept bypass permissions confirmation (v2.1.85+)
- Port fallback (EADDRINUSE → try +1), statusLine shell script wrapper

Other:
- Desktop Enter sends / Shift+Enter newline; Mobile Enter newline
- Strip CLAWTAP_REF marker from session list
- Active sessions tab shows adapter badge
- Rename CLAUDE_UI_PASSWORD → CLAWTAP_PASSWORD

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:46:00 +08:00

243 lines
8.7 KiB
TypeScript

import { readdir, stat } from 'fs/promises';
import { join } from 'path';
import { homedir } from 'os';
import { createReadStream } from 'fs';
import { createInterface } from 'readline';
import { extractText, isSystemMessage, extractPlanContent, isNoResponseMessage, extractSubTools } from './message-utils.js';
import type { JsonlEntry, ContentBlock, SubToolBlock } from './message-utils.js';
import type { DirectoryEntry } from '../interface.js';
// --- Constants ---
export const PROJECTS_DIR: string = join(homedir(), '.claude', 'projects');
// --- Helpers ---
interface SessionDirEntry {
path: string;
cwd: string | null;
}
interface SessionFileInfo {
filePath: string;
sessionId: string;
cwd: string | null;
mtime: Date;
}
export interface SessionHeaderResult {
sessionId: string;
cwd: string | null;
lastModified: string;
firstPrompt: string | null;
model: string | null;
version: string | null;
}
export interface GetMessagesResult {
messages: unknown[];
lastModified: string | null;
}
export function encodeDirName(dir: string): string {
return dir.replace(/[\/ .]/g, '-');
}
export async function getSessionDirs(dir?: string): Promise<SessionDirEntry[]> {
if (dir) {
const encoded = encodeDirName(dir);
return [{ path: join(PROJECTS_DIR, encoded), cwd: dir }];
}
try {
const entries = await readdir(PROJECTS_DIR, { withFileTypes: true });
return entries
.filter((e) => e.isDirectory())
.map((e) => ({ path: join(PROJECTS_DIR, e.name), cwd: null }));
} catch {
return [];
}
}
// --- Cross-device continuity ---
// --- Session Listing (file-based) ---
export async function parseSessionHeader(
filePath: string,
sessionId: string,
{ cwd, mtime }: { cwd?: string | null; mtime?: Date } = {}
): Promise<SessionHeaderResult> {
const fileMtime = mtime || (await stat(filePath)).mtime;
const stream = createReadStream(filePath);
const rl = createInterface({ input: stream, crlfDelay: Infinity });
let sessionCwd: string | null = null;
let firstPrompt: string | null = null;
let sessionModel: string | null = null;
let sessionVersion: string | null = null;
try {
for await (const line of rl) {
if (!line.trim()) continue;
try {
const entry: JsonlEntry = JSON.parse(line);
if (!sessionCwd && entry.cwd) sessionCwd = entry.cwd as string;
if (!sessionModel && entry.model) sessionModel = entry.model as string;
if (!sessionVersion && entry.version) sessionVersion = entry.version as string;
if (!firstPrompt && entry.type === 'user' && entry.message?.content) {
firstPrompt = extractText(entry.message.content);
}
if (sessionCwd && firstPrompt) break;
} catch {}
}
} finally {
rl.close();
stream.destroy();
}
return {
sessionId,
cwd: sessionCwd || cwd || null,
lastModified: fileMtime.toISOString(),
firstPrompt: firstPrompt ? firstPrompt.slice(0, 200) : null,
model: sessionModel,
version: sessionVersion,
};
}
export async function getSessions(dir?: string, limit?: number): Promise<SessionHeaderResult[]> {
const sessionDirs = await getSessionDirs(dir);
const allFiles: SessionFileInfo[] = [];
for (const { path: dirPath, cwd } of sessionDirs) {
let files: string[];
try {
files = await readdir(dirPath);
} catch {
continue;
}
const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
const statResults = await Promise.all(
jsonlFiles.map(async (file): Promise<SessionFileInfo | null> => {
const filePath = join(dirPath, file);
const s = await stat(filePath).catch(() => null);
return s ? { filePath, sessionId: file.replace('.jsonl', ''), cwd, mtime: s.mtime } : null;
})
);
allFiles.push(...statResults.filter((r): r is SessionFileInfo => r !== null));
}
// Sort by mtime first (cheap), then only parse top N headers
allFiles.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
const toParse = limit ? allFiles.slice(0, limit) : allFiles;
const sessions = await Promise.all(
toParse.map(f => parseSessionHeader(f.filePath, f.sessionId, { cwd: f.cwd, mtime: f.mtime }).catch(() => null))
);
return sessions.filter((s): s is SessionHeaderResult => s !== null);
}
export async function getMessages(sessionId: string, dir?: string): Promise<GetMessagesResult> {
const sessionDirs = await getSessionDirs(dir);
for (const { path: dirPath } of sessionDirs) {
const filePath = join(dirPath, `${sessionId}.jsonl`);
try {
const messages: unknown[] = [];
const subToolMap: Map<string, SubToolBlock[]> = new Map(); // parentToolUseId → sub-tool blocks
const toolUseIndex: Map<string, ContentBlock> = new Map(); // tool_use id → content block
const stream = createReadStream(filePath);
const rl = createInterface({ input: stream, crlfDelay: Infinity });
try {
for await (const line of rl) {
if (!line.trim()) continue;
try {
const entry: JsonlEntry = JSON.parse(line);
// Track agent sub-tools from progress entries (for SubagentGroup display)
if (entry.type === 'progress' && entry.data?.type === 'agent_progress') {
const result = extractSubTools(entry);
if (result) {
if (!subToolMap.has(result.parentId)) subToolMap.set(result.parentId, []);
subToolMap.get(result.parentId)!.push(...result.subTools);
}
continue;
}
if (!entry.message) continue;
const content = entry.message.content;
const text = extractText(content);
if (entry.type === 'assistant') {
if (isNoResponseMessage(text)) continue;
messages.push(entry.message);
// Index tool_use blocks for O(1) result attachment
if (Array.isArray(content)) {
for (const block of content as ContentBlock[]) {
if (block.type === 'tool_use' && block.id) toolUseIndex.set(block.id, block);
}
}
} else if (entry.type === 'user') {
// Attach tool results to their matching tool_use blocks
const toolResults = Array.isArray(content)
? (content as ContentBlock[]).filter((b: ContentBlock) => b.type === 'tool_result' && b.tool_use_id)
: [];
if (toolResults.length > 0) {
for (const block of toolResults) {
const match = toolUseIndex.get(block.tool_use_id as string);
if (match) match._result = block;
}
continue;
}
// Skip system/CLI messages (empty text, system patterns)
if (isSystemMessage(text, content)) continue;
// Convert "Implement the following plan:" messages to plan type
const planBody = extractPlanContent(text);
if (planBody !== null) {
messages.push({ role: 'plan', content: planBody });
continue;
}
messages.push(entry.message);
}
} catch {}
}
} finally {
rl.close();
stream.destroy();
}
// Inject accumulated sub-tool blocks into their parent Agent messages
for (const msg of messages) {
const m = msg as { content?: ContentBlock[] };
if (!Array.isArray(m.content)) continue;
for (const block of m.content) {
if (block.type !== 'tool_use') continue;
const subTools = subToolMap.get(block.id!);
if (subTools && subTools.length > 0) {
m.content.push(...(subTools as unknown as ContentBlock[]));
subToolMap.delete(block.id!);
}
}
}
const fileMtime = await stat(filePath);
return { messages, lastModified: fileMtime.mtime.toISOString() };
} catch {
continue;
}
}
return { messages: [], lastModified: null };
}
// --- Directory Browser ---
export async function listDirectory(dirPath?: string): Promise<DirectoryEntry[]> {
const target = dirPath || homedir();
const entries = await readdir(target, { withFileTypes: true });
const visible = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.'));
const dirs = await Promise.all(
visible.map(async (entry): Promise<DirectoryEntry> => {
const fullPath = join(target, entry.name);
let hasChildren = false;
try {
const children = await readdir(fullPath, { withFileTypes: true });
hasChildren = children.some((c) => c.isDirectory() && !c.name.startsWith('.'));
} catch {}
return { name: entry.name, path: fullPath, hasChildren };
})
);
return dirs.sort((a, b) => a.name.localeCompare(b.name));
}