feat: ClawTap v0.1.0 — initial release

Multi-adapter mobile UI for AI coding assistants.
Supports Claude Code, Codex CLI, and Gemini CLI through one interface.

Features:
- Real-time bidirectional sync via tmux + WebSocket
- Cross-AI review (send one AI's output to another for review)
- Multi-review tabs with minimize/expand
- Push notifications (PWA) with smart session-aware filtering
- Three-channel event system (hooks, file watcher, pane monitor)
- Voice input, image paste, draft persistence
- Terminal-native design (JetBrains Mono, dark theme, pixel art claw)
- CLI with --adapter flag on every command
- Zero-overhead fire-and-forget hooks
This commit is contained in:
kuannnn
2026-03-18 10:24:45 +08:00
commit 42861ea7fa
151 changed files with 33897 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
// server/adapters/claude/hook-config.ts
//
// Pure filesystem operations for Claude hook management.
// Zero runtime dependencies — no EventEmitter, no tmux, no sessions.
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
/** Individual hook action (command or url based) */
interface HookAction {
type?: string;
command?: string;
url?: string;
timeout?: number;
}
/** A hook entry within a hook event */
interface HookEntry {
matcher?: string;
hooks: HookAction[];
}
/** Hook identifiers for matching our entries */
interface HookIdentifiers {
portTag: string;
}
/** The structure of Claude's settings.json (partial) */
interface ClaudeSettings {
hooks?: Record<string, HookEntry[]>;
statusLine?: { type: string; command: string };
_clawtapOriginalStatusLine?: string;
[key: string]: unknown;
}
export class ClaudeHookConfig {
/** Shared between install() wrapper construction and _extractOriginalFromWrapper() */
private static readonly WRAPPER_TAIL = `fi; printf '%s' "$input" | `;
port: number | string;
useHttps: boolean;
constructor(port?: number | string, useHttps?: boolean) {
this.port = port || process.env.PORT || 3456;
if (useHttps !== undefined) {
this.useHttps = useHttps;
} else {
// Auto-detect from cert files
const clawtapDir = join(homedir(), '.clawtap');
this.useHttps = existsSync(join(clawtapDir, 'cert.pem')) && existsSync(join(clawtapDir, 'key.pem'));
}
}
/** Install ClawTap hooks into ~/.claude/settings.json */
install(): void {
const port = this.port;
const settingsDir = join(homedir(), '.claude');
const settingsPath = join(settingsDir, 'settings.json');
const { portTag } = this._hookIdentifiers();
const protocol = this.useHttps ? 'https' : 'http';
const hookUrl = `${protocol}://localhost:${port}/api/hooks/claude`;
const desiredHooks = this._buildDesiredHooks(hookUrl);
const statuslineUrl = `${hookUrl}/statusline`;
try {
mkdirSync(settingsDir, { recursive: true });
let existing: ClaudeSettings = {};
try { existing = JSON.parse(readFileSync(settingsPath, 'utf-8')) as ClaudeSettings; } catch {}
// Replace our hooks on every startup (handles HTTP → command upgrade).
// Preserves other tools' hooks by filtering only ClawTap entries.
if (!existing.hooks) existing.hooks = {};
for (const [event, configs] of Object.entries(desiredHooks)) {
const existingEntries = existing.hooks[event] || [];
const filtered = existingEntries.filter(entry => !this._isOurHookEntry(entry, portTag));
existing.hooks[event] = [...filtered, ...configs];
}
// Wrap statusLine to also POST to our server (non-blocking).
// - Has custom statusLine → wrap it (POST + original coexist)
// - No custom statusLine → don't touch it, preserve Claude Code built-in
const existingCmd = existing.statusLine?.command || '';
if (existingCmd && !existingCmd.includes(`:${port}/api/hooks/claude/statusline`)) {
existing._clawtapOriginalStatusLine = existingCmd;
const portCheck = this._portCheckCmd();
const curlK = this.useHttps ? ' -k' : '';
const wrapperCmd = `input=$(cat); if ${portCheck}; then printf '%s' "$input" | curl -sf${curlK} -X POST -H 'Content-Type:application/json' -d @- ${statuslineUrl} &>/dev/null & ${ClaudeHookConfig.WRAPPER_TAIL}${existingCmd}`;
existing.statusLine = { type: 'command', command: wrapperCmd };
console.log(`[hooks] Wrapped statusLine to POST to ${statuslineUrl}`);
}
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
console.log(`[hooks] Auto-configured HTTP hooks in ${settingsPath}`);
} catch (err) {
console.warn(`[hooks] Failed to auto-configure hooks: ${(err as Error).message}`);
}
}
/**
* Remove ClawTap hooks from ~/.claude/settings.json.
* Leaves other user settings intact. Only removes hooks owned by this port.
*/
uninstall(): void {
const { portTag } = this._hookIdentifiers();
const settingsPath = join(homedir(), '.claude', 'settings.json');
try {
const existing: ClaudeSettings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as ClaudeSettings;
// --- Clean up hooks (independent of statusLine) ---
if (existing.hooks) {
const hookKeys = Object.keys(this._buildDesiredHooks(''));
for (const key of hookKeys) {
const entries = existing.hooks[key];
if (!Array.isArray(entries)) continue;
const filtered = entries.filter(entry => !this._isOurHookEntry(entry, portTag));
if (filtered.length === 0) {
delete existing.hooks[key];
} else {
existing.hooks[key] = filtered;
}
}
if (Object.keys(existing.hooks).length === 0) delete existing.hooks;
}
// --- Restore statusLine (independent of hooks) ---
// Restore original statusLine: try extraction from wrapper first (most reliable),
// then fall back to backup field, then delete only if truly no original existed.
if (existing.statusLine?.command?.includes(portTag)) {
const original = this._extractOriginalFromWrapper(existing.statusLine.command);
if (original) {
existing.statusLine = { type: 'command', command: original };
} else if (existing._clawtapOriginalStatusLine) {
existing.statusLine = { type: 'command', command: existing._clawtapOriginalStatusLine };
} else {
delete existing.statusLine;
}
}
delete existing._clawtapOriginalStatusLine;
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
console.log(`[hooks] Removed ClawTap hooks from ${settingsPath}`);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
console.warn(`[hooks] Failed to remove hooks: ${(err as Error).message}`);
}
}
// --- Internal helpers ---
private _hookIdentifiers(): HookIdentifiers {
return {
portTag: `:${this.port}/api/hooks/claude`,
};
}
/** Extract the original statusLine command from our wrapper using WRAPPER_TAIL. */
private _extractOriginalFromWrapper(cmd: string): string | null {
const tail = ClaudeHookConfig.WRAPPER_TAIL;
const idx = cmd.lastIndexOf(tail);
if (idx < 0) return null;
const original = cmd.substring(idx + tail.length).trim();
if (!original || original.includes('/api/hooks/claude')) return null;
return original;
}
private _isOurHookEntry(entry: HookEntry, portTag: string): boolean {
const hooks = entry.hooks || [];
return hooks.some(h =>
(h.url && h.url.includes(portTag)) ||
(h.command && h.command.includes(portTag))
);
}
private _buildDesiredHooks(hookUrl: string): Record<string, HookEntry[]> {
// Fire-and-forget: read stdin, background curl, exit immediately.
// Zero blocking — Claude Code never waits for ClawTap.
// /dev/tcp check: fails instantly (<1ms) if server isn't listening, avoiding 2s curl timeout
// --connect-timeout 2: give up if server unreachable
// --max-time 5: give up if server hangs after accepting connection
const portCheck = this._portCheckCmd();
const curlInsecure = this.useHttps ? ' -k' : '';
const fireAndForget = (endpoint: string): string =>
`${portCheck} || exit 0; input=$(cat); printf '%s' "$input" | curl -sf${curlInsecure} --connect-timeout 2 --max-time 5 -X POST -H 'Content-Type:application/json' -d @- ${hookUrl}/${endpoint} &>/dev/null &`;
return {
SessionStart: [{ hooks: [{ type: 'command', command: fireAndForget('session-start'), timeout: 2 }] }],
UserPromptSubmit: [{ hooks: [{ type: 'command', command: fireAndForget('user-prompt-submit'), timeout: 2 }] }],
PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: fireAndForget('pre-tool-use'), timeout: 2 }] }],
PostToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: fireAndForget('post-tool-use'), timeout: 2 }] }],
PostToolUseFailure: [{ matcher: '*', hooks: [{ type: 'command', command: fireAndForget('post-tool-use-failure'), timeout: 2 }] }],
Stop: [{ hooks: [{ type: 'command', command: fireAndForget('stop'), timeout: 2 }] }],
StopFailure: [{ hooks: [{ type: 'command', command: fireAndForget('stop-failure'), timeout: 2 }] }],
SubagentStop: [{ hooks: [{ type: 'command', command: fireAndForget('stop'), timeout: 2 }] }],
PermissionRequest: [{ matcher: '*', hooks: [{ type: 'command', command: fireAndForget('permission-request'), timeout: 2 }] }],
SessionEnd: [{ hooks: [{ type: 'command', command: fireAndForget('session-end'), timeout: 2 }] }],
PreCompact: [
{ matcher: 'auto', hooks: [{ type: 'command', command: fireAndForget('pre-compact'), timeout: 2 }] },
{ matcher: 'manual', hooks: [{ type: 'command', command: fireAndForget('pre-compact'), timeout: 2 }] },
],
PostCompact: [
{ matcher: 'auto', hooks: [{ type: 'command', command: fireAndForget('post-compact'), timeout: 2 }] },
{ matcher: 'manual', hooks: [{ type: 'command', command: fireAndForget('post-compact'), timeout: 2 }] },
],
};
}
private _portCheckCmd(): string {
return `(echo >/dev/tcp/localhost/${this.port}) 2>/dev/null`;
}
}
+240
View File
@@ -0,0 +1,240 @@
// server/adapters/claude/index.ts
import { IAdapter } from '../interface.js';
import type { DirectoryEntry, ActiveSessionInfo, MessagesResult, CachedStatus } from '../interface.js';
import { TmuxAdapter } from './tmux-adapter.js';
import type { SessionState, HookBody } from './tmux-adapter.js';
import { ClaudeHookConfig } from './hook-config.js';
import {
getSessions, getMessages, listDirectory,
} from './jsonl-store.js';
import type { SessionHeaderResult, GetMessagesResult } from './jsonl-store.js';
import type { QueryOptions, PermissionBehavior } from '../../types/messages.js';
import type { AdapterCapabilities, ModelInfo, PermissionModeInfo, EffortLevelInfo, ReconnectState, SessionInfo } from '../../types/adapter.js';
import type { Express } from 'express';
/** Statusline body from Claude CLI */
interface StatusLineBody {
session_id?: string;
permission_mode?: string;
context_window?: { used_percentage?: number };
model?: { display_name?: string };
cost?: { total_cost_usd?: number };
[key: string]: unknown;
}
const MODELS: ModelInfo[] = [
{ value: 'sonnet', label: 'Sonnet', contextWindow: 200000 },
{ value: 'opus', label: 'Opus', contextWindow: 200000 },
{ value: 'haiku', label: 'Haiku', contextWindow: 200000 },
{ value: 'opus[1m]', label: 'Opus 1M', contextWindow: 1000000 },
{ value: 'sonnet[1m]', label: 'Sonnet 1M', contextWindow: 1000000 },
];
const PERMISSION_MODES: PermissionModeInfo[] = [
{ value: 'default', label: 'Normal' },
{ value: 'acceptEdits', label: 'Auto-edit' },
{ value: 'plan', label: 'Plan' },
{ value: 'bypassPermissions', label: 'YOLO' },
];
const EFFORT_LEVELS: EffortLevelInfo[] = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'max', label: 'Max' },
];
export class ClaudeAdapter extends IAdapter {
static id: string = 'claude';
static displayName: string = 'Claude Code';
static command: string = 'claude';
private _tmux: TmuxAdapter;
private _hookConfig: ClaudeHookConfig;
private _lastStatus: Map<string, CachedStatus>; // sessionId → { contextPercent, model, cost }
constructor() {
super();
this._tmux = new TmuxAdapter();
this._hookConfig = new ClaudeHookConfig();
this._lastStatus = new Map();
// Forward all events from internal tmux adapter
const events: string[] = [
'streaming-text', 'thinking', 'tool-start', 'tool-done',
'tool-updates', 'new-messages', 'session-idle',
'permission-request', 'ask-question', 'mode-changed',
'session-ended', 'session-error', 'compacting', 'compact-done',
'processing-started',
];
for (const event of events) {
this._tmux.on(event, (...args: unknown[]) => this.emit(event, ...args));
}
// Clean up statusline dedup cache when session ends
this._tmux.on('session-ended', (sessionId: string) => {
this._lastStatus.delete(sessionId);
});
}
setup(app: Express): void {
this.installHooks();
this._registerHookRoutes(app);
}
installHooks(): void { this._hookConfig.install(); }
uninstallHooks(): void { this._hookConfig.uninstall(); }
async cleanup(): Promise<void> {
this.uninstallHooks();
await this._tmux.destroy();
}
/**
* Register Express routes for Claude-specific hooks.
* These are called by the Claude CLI from localhost (no auth needed).
*/
private _registerHookRoutes(app: Express): void {
// All hooks are fire-and-forget notifications — no return value used.
// Handlers are called for side effects only (emit events, update state).
const hookRoute = (path: string, handler: (body: HookBody) => void | Promise<void>): void => {
const label = path.split('/').pop();
app.post(path, (req: any, res: any) => {
const sid = req.body.session_id?.substring(0, 8) || '?';
const toolInfo = req.body.tool_name ? ` ${req.body.tool_name}` : '';
console.log(`[hook] ${label}:${toolInfo} sid=${sid}`);
try {
const result = handler(req.body);
if (result instanceof Promise) result.catch((e: Error) => console.error(`[hook] ${label} error:`, e.message));
} catch (e) { console.error(`[hook] ${label} error:`, (e as Error).message); }
res.json({});
});
};
const prefix = this.getHookPrefix(); // /api/hooks/claude
hookRoute(`${prefix}/pre-tool-use`, (body) => {
this._tmux.handlePreToolUse(body);
});
hookRoute(`${prefix}/post-tool-use`, (body) => {
this._tmux.handlePostToolUse(body);
});
hookRoute(`${prefix}/stop`, (body) => {
this._tmux.handleStop(body);
});
hookRoute(`${prefix}/permission-request`, (body) => {
this._tmux.handlePermissionRequest(body);
});
hookRoute(`${prefix}/user-prompt-submit`, (body) => {
this._tmux.handleUserPromptSubmit(body);
});
hookRoute(`${prefix}/session-end`, (body) => {
this._tmux.handleSessionEnd(body);
});
hookRoute(`${prefix}/post-tool-use-failure`, (body) => {
this._tmux.handlePostToolUseFailure(body);
});
hookRoute(`${prefix}/stop-failure`, (body) => {
this._tmux.handleStopFailure(body);
});
hookRoute(`${prefix}/pre-compact`, (body) => {
this._tmux.handlePreCompact(body);
});
hookRoute(`${prefix}/post-compact`, (body) => {
this._tmux.handlePostCompact(body);
});
hookRoute(`${prefix}/session-start`, (body) => {
this._tmux.handleSessionStart(body);
});
hookRoute(`${prefix}/statusline`, (body) => {
this._handleStatusLine(body as StatusLineBody);
});
}
/**
* Handle statusline hook — extract metrics, sync permission mode,
* deduplicate, and emit 'status-update' event.
*/
private _handleStatusLine(body: StatusLineBody): void {
const sessionId = body.session_id;
if (!sessionId || !this._tmux.getSession(sessionId)) return;
// Sync permission mode from statusline — catches desktop Shift+Tab changes
// that don't trigger other hooks (PreToolUse, Stop, etc.)
this._tmux.syncPermissionMode(sessionId, body);
const contextPercent = body.context_window?.used_percentage ?? null;
const model = body.model?.display_name ?? null;
const cost = body.cost?.total_cost_usd ?? null;
// Deduplicate — skip if nothing changed
const prev = this._lastStatus.get(sessionId);
if (prev &&
prev.contextPercent === contextPercent &&
prev.model === model &&
prev.cost === cost) return;
const status: CachedStatus = { contextPercent, model, cost };
this._lastStatus.set(sessionId, status);
this.emit('status-update', sessionId, status);
}
setClientChecker(fn: (sessionId: string) => boolean): void {
this._tmux.setClientChecker(fn);
}
// Lifecycle — delegate to tmux adapter
async startSession(cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { return this._tmux.startSession(cwd, options); }
async resumeSession(sid: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { return this._tmux.resumeSession(sid, cwd, options); }
async attachSession(sid: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { return this._tmux.attachSession(sid, cwd, options); }
async destroySession(sid: string): Promise<void> { return this._tmux.destroySession(sid); }
async sendMessage(sid: string, text: string, options?: QueryOptions): Promise<void> { return this._tmux.sendMessage(sid, text, options); }
async respondPlan(sid: string, optionIndex: number, text?: string): Promise<void> { return this._tmux.respondPlan(sid, optionIndex, text); }
async switchModel(sid: string, model: string): Promise<void> { return this._tmux.switchModel(sid, model); }
async interrupt(sid: string): Promise<void> { return this._tmux.interrupt(sid); }
flushMessages(sid: string): void { this._tmux.flushMessages(sid); }
syncWatcherPosition(sid: string): void { this._tmux.syncWatcherPosition(sid); }
getReconnectState(sid: string): ReconnectState { return this._tmux.getReconnectState(sid); }
// Store — delegate to jsonl-store
async getSessions(dir?: string, limit?: number): Promise<SessionInfo[]> { return getSessions(dir, limit); }
async getMessages(sid: string, dir?: string): Promise<GetMessagesResult> { return getMessages(sid, dir); }
async listDirectory(path?: string): Promise<DirectoryEntry[]> { return listDirectory(path); }
// Permissions — delegate to tmux adapter
async switchPermissionMode(sid: string, mode: string): Promise<boolean> { return this._tmux.switchPermissionMode(sid, mode); }
respondPermission(reqId: string, behavior: PermissionBehavior): void { this._tmux.respondPermission(reqId, behavior); }
async respondQuestion(reqId: string, answer: string): Promise<void> { return this._tmux.respondQuestion(reqId, answer); }
releaseAllPending(sid: string): void { this._tmux.releaseAllPending(sid); }
resolveAllPendingAs(sid: string, behavior: PermissionBehavior): void { this._tmux.resolveAllPendingAs(sid, behavior); }
// Query
isProcessing(sid: string): boolean { return this._tmux.isProcessing(sid); }
getSession(sid: string): SessionState | undefined { return this._tmux.getSession(sid); }
getLastStatus(sid: string) { return this._lastStatus.get(sid) || null; }
async hasActiveWindow(sid: string): Promise<boolean> { return this._tmux.hasActiveWindow(sid); }
getActiveSessions(): ActiveSessionInfo[] { return this._tmux.getActiveSessions(); }
// Capabilities
getModels(): ModelInfo[] { return MODELS; }
getPermissionModes(): PermissionModeInfo[] { return PERMISSION_MODES; }
getEffortLevels(): EffortLevelInfo[] { return EFFORT_LEVELS; }
getEffortLabel(): string { return 'Thinking'; }
getCapabilities(): AdapterCapabilities {
return {
supportsPlanMode: true,
supportsPermissionModes: true,
supportsInterrupt: true,
supportsResume: true,
supportsAttach: true,
supportsStatusLine: true,
supportsImages: true,
supportsStreaming: true,
maxContextWindow: 1_000_000,
permissionModeType: 'cycle',
};
}
}
+226
View File
@@ -0,0 +1,226 @@
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 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);
} else if (entry.type === 'user') {
// Skip messages containing tool results (not needed for display)
if (Array.isArray(content) && content.some((b: ContentBlock) => b.type === 'tool_result')) 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));
}
+105
View File
@@ -0,0 +1,105 @@
/** A content block within a Claude message */
export interface ContentBlock {
type: string;
text?: string;
id?: string;
name?: string;
input?: Record<string, unknown>;
tool_use_id?: string;
content?: string;
is_error?: boolean;
[key: string]: unknown;
}
/** A sub-tool block extracted from agent_progress entries */
export interface SubToolBlock {
type: 'tool_use';
id: string;
name: string;
input: Record<string, unknown>;
parent_tool_use_id: string;
}
/** Result of extractSubTools */
export interface SubToolsResult {
parentId: string;
subTools: SubToolBlock[];
}
// TODO: type properly — JSONL entries have various shapes
export interface JsonlEntry {
type?: string;
message?: {
role?: string;
content?: string | ContentBlock[];
[key: string]: unknown;
};
content?: string | ContentBlock[];
data?: {
type?: string;
message?: {
message?: {
role?: string;
content?: ContentBlock[];
[key: string]: unknown;
};
[key: string]: unknown;
};
[key: string]: unknown;
};
parentToolUseID?: string;
cwd?: string;
model?: string;
version?: string;
[key: string]: unknown;
}
const PLAN_PREFIX = /^Implement the following plan:\s*/i;
export const SYSTEM_PATTERNS: RegExp[] = [
/^(Base directory for this skill:|Continue from where you left off)/i,
/<(command-message|command-name|command-args|local-command|task-notification|system-reminder)/i,
];
export function extractText(content: string | ContentBlock[] | unknown): string {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.filter((b: ContentBlock) => b.type === 'text')
.map((b: ContentBlock) => b.text)
.join('\n');
}
return '';
}
export function isSystemMessage(text: string, content: string | ContentBlock[] | unknown): boolean {
if (!text.trim()) return true;
if (Array.isArray(content) && content.every((b: ContentBlock) => b.type === 'tool_result')) return true;
for (const pattern of SYSTEM_PATTERNS) {
if (pattern.test(text)) return true;
}
return false;
}
/** Returns plan body text if this is a plan message, null otherwise. */
export function extractPlanContent(text: string): string | null {
return PLAN_PREFIX.test(text) ? text.replace(PLAN_PREFIX, '') : null;
}
export function isNoResponseMessage(text: string): boolean {
return /^No response requested/i.test(text.trim());
}
/** Extract sub-tool blocks from an agent_progress JSONL entry. */
export function extractSubTools(progressEntry: JsonlEntry): SubToolsResult | null {
const parentId = progressEntry.parentToolUseID;
const msg = progressEntry.data?.message?.message;
if (!parentId || !msg || msg.role !== 'assistant' || !Array.isArray(msg.content)) return null;
const subTools: SubToolBlock[] = [];
for (const block of msg.content) {
if (block.type === 'tool_use') {
subTools.push({ type: 'tool_use', id: block.id!, name: block.name!, input: block.input as Record<string, unknown>, parent_tool_use_id: parentId });
}
}
return subTools.length > 0 ? { parentId, subTools } : null;
}
+130
View File
@@ -0,0 +1,130 @@
import { tmuxManager } from '../shared/tmux-manager.js';
/** Thinking indicator detected from pane content */
export interface ThinkingInfo {
text: string;
detail: string | null;
}
/**
* Simplified PaneMonitor — only detects:
* 1. Thinking indicator (spinner + verb)
* 2. Streaming response text (text after ⏺ marker)
*
* Permission mode detection removed — handled by syncPermissionMode() in
* tmux-adapter via hook body's permission_mode field (including statusline).
* Permission, question, and idle detection handled by HTTP hooks
* (PreToolUse, PostToolUse, PermissionRequest, Stop).
*/
export class PaneMonitor {
windowId: string;
lastContent: string;
interval: ReturnType<typeof setInterval> | null;
private _lastResponseText: string;
private _onThinking: ((thinking: ThinkingInfo) => void) | null;
private _onStreamingText: ((text: string) => void) | null;
constructor(windowId: string) {
this.windowId = windowId;
this.lastContent = '';
this.interval = null;
this._lastResponseText = '';
this._onThinking = null;
this._onStreamingText = null;
}
start(): void {
this.interval = setInterval(async () => {
try {
const content = await tmuxManager.capturePane(this.windowId);
if (content === this.lastContent) return;
this.lastContent = content;
// 1. Check thinking (spinner in status area)
const thinking = detectThinking(content);
if (thinking && this._onThinking) {
this._onThinking(thinking);
}
// 2. Extract streaming response text
if (this._onStreamingText && !thinking) {
const text = extractResponseText(content);
if (text && text !== this._lastResponseText) {
this._lastResponseText = text;
this._onStreamingText(text);
}
}
} catch (err) {
// Silently ignore — window may have been killed
}
}, 500);
}
stop(): void {
if (this.interval) { clearInterval(this.interval); this.interval = null; }
}
onThinking(cb: (thinking: ThinkingInfo) => void): void { this._onThinking = cb; }
onStreamingText(cb: (text: string) => void): void { this._onStreamingText = cb; }
}
// --- Detection functions ---
export function detectThinking(content: string): ThinkingInfo | null {
const lines = content.split('\n');
const tail = lines.slice(-15);
for (const line of tail) {
// Match: spinner char + word ending in "…", with optional (detail)
// But NOT "Worked for" (completion summary)
if (/Worked for|completed|Done/i.test(line)) continue;
const match = line.match(/^\s*([✶✻·✽✳✢])\s+(\S+…)\s*(?:\((.+?)\))?\s*$/);
if (match) {
return { text: match[2]!, detail: match[3] || null };
}
}
return null;
}
export function extractResponseText(content: string): string {
const lines = content.split('\n');
// Find the LAST user prompt ( with text) — only look for responses AFTER it
let lastUserPrompt = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (/^\s*\s+\S/.test(lines[i]!)) {
lastUserPrompt = i;
break;
}
}
// Find the response ⏺ AFTER the last user prompt
let lastResponseStart = -1;
const searchStart = lastUserPrompt >= 0 ? lastUserPrompt : 0;
for (let i = lines.length - 1; i >= searchStart; i--) {
const line = lines[i]!;
// Skip tool calls: ⏺ CapitalWord( or ⏺ Read/Write N file
if (/^\s*⏺\s+[A-Z]\w*[\(]/.test(line)) continue;
if (/^\s*⏺\s+[A-Z]\w+\s+\d+\s+file/.test(line)) continue;
if (/^\s*⏺\s+/.test(line)) {
lastResponseStart = i;
break;
}
}
if (lastResponseStart === -1) return '';
const responseLines = [lines[lastResponseStart]!.replace(/^\s*⏺\s?/, '')];
for (let i = lastResponseStart + 1; i < lines.length; i++) {
const line = lines[i]!;
if (/^[─━═]{5,}/.test(line.trim()) ||
/^\s*/.test(line) ||
/^\s*⎿/.test(line) ||
/^\s*⏺/.test(line) ||
/^\s*[✶✻·✽✳✢]\s+/.test(line)) {
break;
}
responseLines.push(line);
}
return responseLines.join('\n').trim();
}
+893
View File
@@ -0,0 +1,893 @@
import { EventEmitter } from 'events';
import { tmuxManager } from '../shared/tmux-manager.js';
import type { TmuxWindow } from '../shared/tmux-manager.js';
import { PaneMonitor } from './pane-monitor.js';
import { JsonlWatcher } from '../../stores/jsonl-watcher.js';
import { TranscriptParser } from './transcript-parser.js';
import type { ParsedMessage } from './transcript-parser.js';
import { readdir, stat } from 'fs/promises';
import { join } from 'path';
import crypto from 'crypto';
import { PROJECTS_DIR, encodeDirName, parseSessionHeader } from './jsonl-store.js';
import { extractText } from './message-utils.js';
import type { JsonlEntry } from './message-utils.js';
import type { PermissionBehavior, QueryOptions } from '../../types/messages.js';
import type { ReconnectState } from '../../types/adapter.js';
import type { ActiveSessionInfo } from '../interface.js';
import { isLargeContent } from '../interface.js';
import { PermissionManager } from '../../permission-manager.js';
import { PLAN_OPTION } from '../../ws-types.js';
const MODE_CYCLE: string[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
/** Internal session state for a managed tmux session */
export interface SessionState {
windowId: string;
monitor: PaneMonitor | null;
watcher: JsonlWatcher | null;
parser: TranscriptParser | null;
cwd: string;
cliSessionId: string;
permissionMode: string;
lastActivity: number;
firstPrompt: string | null;
isProcessing: boolean;
isNonInteractive: boolean;
_interactiveChecked: boolean;
_promptSenderClientId: string | null;
_modeTransitionDeadline: number;
_watcherPending: boolean;
}
/** Hook body payload from Claude CLI */
export interface HookBody {
session_id?: string;
permission_mode?: string;
tool_use_id?: string;
tool_name?: string;
tool_input?: Record<string, unknown>;
tool_response?: unknown;
error?: string;
error_details?: string;
is_interrupt?: boolean;
[key: string]: unknown;
}
/** Resolved session context from _resolveAndTouch */
interface ResolvedContext {
sessionId: string;
session: SessionState | undefined;
}
/**
* TmuxAdapter — manages Claude Code sessions via tmux.
*
* Three channels provide events to the SessionManager:
* 1. HTTP Hooks (structured): tool-start, tool-done, session-idle, permission-request
* 2. JSONL Watcher (messages): new-messages (single source of truth)
* 3. PaneMonitor (ephemeral): streaming-text, thinking
*
* Events emitted:
* streaming-text(sessionId, text)
* thinking(sessionId, { text, detail })
* tool-start(sessionId, { toolId, toolName, input })
* tool-done(sessionId, { toolId, toolName, result })
* new-messages(sessionId, messages[])
* session-idle(sessionId)
* session-error(sessionId, { errorType, errorDetails })
* permission-request(sessionId, { requestId, toolName, input })
* ask-question(sessionId, { requestId, toolName, input })
* mode-changed(sessionId, mode)
* session-ended(sessionId)
* compacting(sessionId)
* compact-done(sessionId)
* processing-started(sessionId)
*/
export class TmuxAdapter extends EventEmitter {
// sessionId (CLI UUID) -> { windowId, monitor, watcher, parser, cwd, cliSessionId, permissionMode }
sessions: Map<string, SessionState>;
// Centralized pending permissions/questions manager
private _permissions: PermissionManager;
// Set by SessionManager to check if WS clients are connected
private _clientChecker: ((sessionId: string) => boolean) | null;
private _cleanupInterval: ReturnType<typeof setInterval> | null;
// CLI permission prompt option layout (Claude CLI v2.x):
// 0: "Yes"
// 1: "Yes, allow all edits during this session (shift+tab)"
// 2: "No"
static PERMISSION_DENY_INDEX: number = 2;
constructor() {
super();
this.sessions = new Map();
this._permissions = new PermissionManager();
this._clientChecker = null;
this._cleanupInterval = null;
this._startSessionCleanup();
}
/** Set a function that checks if WS clients are connected for a session */
setClientChecker(fn: (sessionId: string) => boolean): void { this._clientChecker = fn; }
// === Session Lifecycle ===
async startSession(cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> {
// Generate UUID upfront — no guessing needed
const cliSessionId = crypto.randomUUID();
const mode = options.permissionMode || 'default';
const parts = ['claude', '--session-id', cliSessionId];
// Always start with bypass so all 4 modes are reachable mid-session via Shift+Tab
parts.push('--dangerously-skip-permissions');
if (options.model) parts.push('--model', `'${options.model}'`);
if (options.effort) parts.push('--effort', options.effort);
const sessionId = cliSessionId;
const windowId = await tmuxManager.createWindow(sessionId, cwd, parts.join(' '));
// Register session BEFORE _waitForReady — SessionStart hook fires during the wait,
// and needs the session in the Map to avoid creating a duplicate session/watcher.
this.sessions.set(sessionId, this._createSession(windowId, cwd, cliSessionId, mode));
await this._waitForReady(windowId);
this._startMonitor(sessionId, windowId);
this._ensureWatcher(sessionId);
// Switch to user's desired mode (if not already bypassPermissions)
if (mode && mode !== 'bypassPermissions') {
await this.switchPermissionMode(sessionId, mode);
}
return { sessionId };
}
async attachSession(sessionId: string, cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> {
const existing = this.sessions.get(sessionId);
// If already attached with a watcher, don't recreate
if (existing?.watcher) {
if (!existing.monitor) this._startMonitor(sessionId, existing.windowId);
if (options.permissionMode) existing.permissionMode = options.permissionMode;
return { sessionId };
}
const windowId = await this._findWindowForSession(sessionId);
if (!windowId) throw new Error(`No tmux window found for session ${sessionId}`);
// Defensive: if another session already manages this tmux window,
// redirect to it instead of creating a duplicate entry.
// Each tmux window runs exactly one Claude CLI — same window = same session.
if (!existing) {
for (const [existingId, existingSession] of this.sessions) {
if (existingSession.windowId === windowId) {
if (!existingSession.monitor) this._startMonitor(existingId, windowId);
return { sessionId: existingId };
}
}
}
// Preserve existing watcher/parser if session entry exists
if (existing) {
existing.windowId = windowId;
existing.lastActivity = Date.now();
if (options.permissionMode) existing.permissionMode = options.permissionMode;
if (!existing.monitor) this._startMonitor(sessionId, windowId);
} else {
this.sessions.set(sessionId, this._createSession(windowId, cwd, sessionId, options.permissionMode || 'default'));
this._startMonitor(sessionId, windowId);
}
await this._ensureWatcher(sessionId);
return { sessionId };
}
async resumeSession(sessionId: string, cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> {
const mode = options.permissionMode || 'default';
const windows = await tmuxManager.listWindows();
// Extract CLI UUID before potentially deleting the session
const existingSession = this.sessions.get(sessionId);
const cliUuid = existingSession?.cliSessionId || sessionId;
// Check if session already managed and tmux window still exists
if (existingSession) {
if (await this._windowExists(existingSession.windowId, windows)) {
if (!existingSession.monitor) this._startMonitor(sessionId, existingSession.windowId);
existingSession.permissionMode = mode;
existingSession.lastActivity = Date.now();
await this._ensureWatcher(sessionId);
return { sessionId };
}
// Window gone — stop old watcher before replacing
this._teardownSession(existingSession);
this.sessions.delete(sessionId);
}
// Check for existing tmux window (e.g., started from Desktop)
const existingWindowId = await this._findWindowForSession(cliUuid, windows);
if (existingWindowId) {
return this.attachSession(sessionId, cwd, options);
}
// No existing window — create new with --resume
const modeFlag = '--dangerously-skip-permissions';
let command = `claude ${modeFlag} --resume ${cliUuid}`;
if (options.effort) command += ` --effort ${options.effort}`;
const newSessionId = cliUuid;
const windowId = await tmuxManager.createWindow(cliUuid, cwd || process.cwd(), command);
// Register before _waitForReady (same pattern as startSession)
this.sessions.set(newSessionId, this._createSession(windowId, cwd, cliUuid, mode));
await this._waitForReady(windowId);
this._startMonitor(newSessionId, windowId);
await this._ensureWatcher(newSessionId);
return { sessionId: newSessionId };
}
async sendMessage(sessionId: string, text: string, options: QueryOptions = {}): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) throw new Error(`Session ${sessionId} not found`);
session._promptSenderClientId = options.clientId || null;
// Restart pane monitor if it was stopped (e.g., after turn-complete)
if (!session.monitor) {
this._startMonitor(sessionId, session.windowId);
}
if (isLargeContent(text)) {
// Large/multiline content: use pasteBuffer for speed.
// Claude CLI handles multiline input natively — no \n replacement needed.
// pasteBuffer defaults sendEnter=true, so Enter is sent automatically.
await tmuxManager.pasteBuffer(session.windowId, text);
} else {
await tmuxManager.sendKeys(session.windowId, text, true);
}
}
async switchModel(sessionId: string, model: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
await tmuxManager.sendKeys(session.windowId, `/model ${model}`, true);
}
async interrupt(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
await tmuxManager.sendControl(session.windowId, 'C-c');
}
async destroySession(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
this._teardownSession(session);
await tmuxManager.killWindow(session.windowId);
this.sessions.delete(sessionId);
this.emit('session-ended', sessionId);
}
getSession(sessionId: string): SessionState | undefined {
return this.sessions.get(sessionId);
}
/** Force an immediate JSONL poll for a session */
flushMessages(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session?.watcher) session.watcher.pollNow();
}
/** Advance watcher past current file position without emitting entries */
syncWatcherPosition(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session?.watcher) session.watcher.markCurrentPosition();
}
/** Get pending state for reconnecting clients (tools, permissions, questions) */
getReconnectState(sessionId: string): ReconnectState {
const session = this.sessions.get(sessionId);
const state: ReconnectState = { tools: {}, pendingRequests: [] };
if (session?.parser) {
const tools = session.parser.getPendingTools();
if (tools.size > 0) {
// PendingTool is a superset of ToolStatus — cast is safe for reconnect replay
state.tools = Object.fromEntries(tools) as unknown as Record<string, import('../../types/messages.js').ToolStatus>;
}
}
for (const perm of this._permissions.getPendingForSession(sessionId)) {
state.pendingRequests.push({ type: 'permission', requestId: perm.requestId, toolName: perm.toolName, input: perm.input });
}
for (const q of this._permissions.getQuestionsForSession(sessionId)) {
state.pendingRequests.push({ type: 'question', requestId: q.requestId, toolName: 'AskUserQuestion', input: q.originalInput });
}
return state;
}
async hasActiveWindow(sessionId: string): Promise<boolean> {
const windows = await tmuxManager.listWindows();
const session = this.sessions.get(sessionId);
if (session) return this._windowExists(session.windowId, windows);
// Check if a tmux window exists for this session
return !!(await this._findWindowForSession(sessionId, windows));
}
// === Permission Mode ===
setPermissionMode(sessionId: string, mode: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
session.permissionMode = mode;
return true;
}
async switchPermissionMode(sessionId: string, targetMode: string): Promise<boolean> {
const session = this.sessions.get(sessionId);
if (!session) return false;
const currentMode = session.permissionMode || 'default';
if (currentMode === targetMode) return true;
const currentIdx = MODE_CYCLE.indexOf(currentMode);
const targetIdx = MODE_CYCLE.indexOf(targetMode);
if (currentIdx < 0 || targetIdx < 0) return false;
const presses = (targetIdx - currentIdx + MODE_CYCLE.length) % MODE_CYCLE.length;
// Set target BEFORE sending keys — prevents syncPermissionMode
// from overwriting with intermediate modes during the Shift+Tab transition
session.permissionMode = targetMode;
session._modeTransitionDeadline = Date.now() + presses * 200 + 500;
for (let i = 0; i < presses; i++) {
await tmuxManager.sendControl(session.windowId, 'BTab');
await new Promise<void>(r => setTimeout(r, 150));
}
return true;
}
// Permission mode precedence (highest → lowest):
// 1. switchPermissionMode() — user-initiated from ClawTap UI, sets target immediately
// 2. syncPermissionMode() — CLI reports its mode via hook body (authoritative)
// 3. Client localStorage — persists user preference across sessions
/**
* Sync permission mode from CLI hook body. Called by hook handlers
* (via _resolveAndTouch) and by statusline handler to catch desktop
* Shift+Tab changes that don't trigger tool-use hooks.
*/
syncPermissionMode(sessionId: string, body: HookBody): void {
if (!body.permission_mode) return;
const session = this.sessions.get(sessionId);
if (!session) return;
// Skip sync while ClawTap-initiated Shift+Tab mode transition is in flight
if (session._modeTransitionDeadline && Date.now() < session._modeTransitionDeadline) return;
const cliMode = body.permission_mode === 'dontAsk' ? 'bypassPermissions' : body.permission_mode;
if (session.permissionMode !== cliMode) {
session.permissionMode = cliMode;
this.emit('mode-changed', sessionId, cliMode);
}
}
// === Hook Handlers (called from Express endpoints) ===
//
// Common preamble extracted into _resolveAndTouch():
// resolve session from body.session_id → syncPermissionMode → update lastActivity
// handleSessionEnd bypasses the helper (needs different teardown logic).
/**
* Resolve hook body to internal session, sync permission mode, touch lastActivity.
* Returns { sessionId, session } or null if session cannot be resolved.
*/
private _resolveAndTouch(body: HookBody): ResolvedContext | null {
const sessionId = body.session_id;
if (!sessionId || !this.sessions.has(sessionId)) return null;
this.syncPermissionMode(sessionId, body);
const session = this.sessions.get(sessionId);
if (session) session.lastActivity = Date.now();
return { sessionId, session };
}
/** Shared by handlePostToolUse and handlePostToolUseFailure. */
private _emitToolDone(sessionId: string, body: HookBody, result: unknown): void {
this.emit('tool-done', sessionId, {
toolId: body.tool_use_id,
toolName: body.tool_name,
input: body.tool_input,
result,
});
this._permissions.dismissAll(sessionId);
}
/** Shared by handleStop and handleStopFailure. */
private _endTurn(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session) {
session.isProcessing = false;
if (session.monitor) {
session.monitor.stop();
session.monitor = null;
}
}
this.emit('session-idle', sessionId);
this._permissions.dismissAll(sessionId);
}
async handlePreToolUse(body: HookBody): Promise<void> {
const ctx = this._resolveAndTouch(body);
if (!ctx) return;
// AskUserQuestion: emit for Mobile picker UI. CLI shows terminal prompt,
// mobile answers via tmux send-keys.
if (body.tool_name === 'AskUserQuestion') {
const requestId = `ask-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
this._permissions.addQuestion(requestId, ctx.sessionId, { originalInput: body.tool_input || {} });
this.emit('ask-question', ctx.sessionId, {
requestId,
toolName: 'AskUserQuestion',
input: body.tool_input,
});
return;
}
this.emit('tool-start', ctx.sessionId, {
toolId: body.tool_use_id,
toolName: body.tool_name,
input: body.tool_input,
});
}
async handlePostToolUse(body: HookBody): Promise<void> {
const ctx = this._resolveAndTouch(body);
if (!ctx) return;
this._emitToolDone(ctx.sessionId, body, body.tool_response);
}
async handlePostToolUseFailure(body: HookBody): Promise<void> {
const ctx = this._resolveAndTouch(body);
if (!ctx) return;
this._emitToolDone(ctx.sessionId, body, {
content: body.error, is_error: true, is_interrupt: body.is_interrupt,
});
}
async handleUserPromptSubmit(body: HookBody): Promise<void> {
const ctx = this._resolveAndTouch(body);
if (!ctx) return;
const { sessionId, session } = ctx;
if (session) {
session.isProcessing = true;
this.emit('processing-started', sessionId);
// Do NOT markCurrentPosition() here — other mobile clients need to see the user message via JSONL.
// The sender deduplicates via senderClientId on the client side.
if (!session.monitor) this._startMonitor(sessionId, session.windowId);
}
this._detectNonInteractive(sessionId);
}
async handleStop(body: HookBody): Promise<void> {
const ctx = this._resolveAndTouch(body);
if (!ctx) return;
this._endTurn(ctx.sessionId);
}
async handleStopFailure(body: HookBody): Promise<void> {
const ctx = this._resolveAndTouch(body);
if (!ctx) return;
this.emit('session-error', ctx.sessionId, {
errorType: body.error,
errorDetails: body.error_details,
});
this._endTurn(ctx.sessionId);
}
async handleSessionEnd(body: HookBody): Promise<void> {
const sessionId = body.session_id;
if (!sessionId) return;
const session = this.sessions.get(sessionId);
if (session) {
this._teardownSession(session);
this.sessions.delete(sessionId);
}
this.emit('session-ended', sessionId);
}
async handlePreCompact(body: HookBody): Promise<void> {
const ctx = this._resolveAndTouch(body);
if (!ctx) return;
this.emit('compacting', ctx.sessionId);
}
async handlePostCompact(body: HookBody): Promise<void> {
const ctx = this._resolveAndTouch(body);
if (!ctx) return;
this.emit('compact-done', ctx.sessionId);
}
/** Handle real-time session discovery when CLI starts (SessionStart hook). */
async handleSessionStart(body: HookBody): Promise<void> {
const cliUuid = body.session_id;
if (!cliUuid) return;
if (this.sessions.has(cliUuid)) {
this.sessions.get(cliUuid)!.lastActivity = Date.now();
return;
}
// Unknown UUID — not our session, ignore
}
/**
* Fire-and-forget notification — no return value.
* YOLO/Auto-edit: CLI handles auto-allow via Shift+Tab, skip mobile overlay.
* Normal: emit permission-request for mobile overlay. User answers via
* tmux send-keys ('y'/'n'), not via hook response.
*/
async handlePermissionRequest(body: HookBody): Promise<void> {
const ctx = this._resolveAndTouch(body);
if (!ctx) return;
const { sessionId, session } = ctx;
const mode = session?.permissionMode || 'default';
// YOLO/Auto-edit: CLI already auto-allows via Shift+Tab — skip mobile overlay
if (mode === 'bypassPermissions') return;
if (mode === 'acceptEdits' && ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(body.tool_name!)) return;
// Plan tools have their own approval UI (PlanMode card) — skip generic overlay.
// AskUserQuestion is handled by PreToolUse (question overlay, not permission overlay).
if (['ExitPlanMode', 'EnterPlanMode', 'AskUserQuestion'].includes(body.tool_name!)) return;
// Normal mode: notify mobile to show permission overlay
const requestId = crypto.randomUUID();
// Store truncated input for reconnect replay — full payload already broadcast via emit below
const inputSummary: Record<string, unknown> = body.tool_input ? Object.fromEntries(
Object.entries(body.tool_input).map(([k, v]) => [k, typeof v === 'string' && v.length > 500 ? v.substring(0, 500) + '\u2026' : v])
) : {};
this._permissions.addPermission(requestId, sessionId, { toolName: body.tool_name!, input: inputSummary });
this.emit('permission-request', sessionId, {
requestId,
toolName: body.tool_name,
input: body.tool_input,
});
}
async respondPermission(requestId: string, behavior: PermissionBehavior): Promise<void> {
const pending = this._permissions.resolvePermission(requestId);
if (!pending) return;
const session = this.sessions.get(pending.sessionId);
if (!session) return;
const optionIndex = behavior === 'allow' ? 0
: behavior === 'allow_session' ? 1
: TmuxAdapter.PERMISSION_DENY_INDEX;
await this._selectOption(session.windowId, optionIndex);
}
/**
* Release all pending requests for a session (e.g., when Mobile disconnects).
* Just clears pending state — CLI prompt remains on terminal.
*/
releaseAllPending(sessionId: string): void {
this._permissions.dismissAll(sessionId);
}
resolveAllPendingAs(sessionId: string, behavior: PermissionBehavior | string): void {
const resolvedIds = this._permissions.resolveAllAs(sessionId, behavior as string);
if (behavior === 'allow') {
const session = this.sessions.get(sessionId);
if (session) {
for (const _reqId of resolvedIds) {
this._selectOption(session.windowId, 0).catch(() => {});
}
}
}
}
async respondQuestion(requestId: string, answer: string): Promise<void> {
const pending = this._permissions.resolveQuestion(requestId);
if (!pending) return;
const input = pending.originalInput || {};
const questions = (input.questions as Array<{ options?: Array<{ label?: string; value?: string }> }>) || [];
const options = questions[0]?.options || [];
const optionIndex = options.findIndex(o => o.label === answer || o.value === answer);
const session = this.sessions.get(pending.sessionId);
if (!session) return;
if (optionIndex >= 0) {
// Matched a predefined option — select it directly
await this._selectOption(session.windowId, optionIndex);
} else {
// Free-form answer — select "Type something" (at index options.length) then type answer
await this._selectOption(session.windowId, options.length);
await new Promise<void>(r => setTimeout(r, 200));
await tmuxManager.sendKeys(session.windowId, answer, true);
}
}
/**
* Respond to the CLI's plan approval selector.
* Options: 0=bypass (auto-accept edits), 1=manually approve, 2=text feedback
*/
async respondPlan(sessionId: string, optionIndex: number, text?: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session || optionIndex < 0 || optionIndex > PLAN_OPTION.TEXT_FEEDBACK) return;
if (optionIndex === PLAN_OPTION.TEXT_FEEDBACK && text) {
await this._selectOption(session.windowId, PLAN_OPTION.TEXT_FEEDBACK);
await new Promise<void>(r => setTimeout(r, 200));
await tmuxManager.sendKeys(session.windowId, text, true);
} else {
await this._selectOption(session.windowId, optionIndex);
}
}
/**
* Navigate a CLI interactive selector by pressing Down `index` times, then Enter.
* Cursor starts on option 0 (first item), so index=0 just presses Enter.
*/
private async _selectOption(windowId: string, index: number): Promise<void> {
for (let i = 0; i < index; i++) {
await tmuxManager.sendControl(windowId, 'Down');
await new Promise<void>(r => setTimeout(r, 100));
}
await tmuxManager.sendControl(windowId, 'Enter');
}
getActiveSessions(): ActiveSessionInfo[] {
const sessions: ActiveSessionInfo[] = [];
for (const [sessionId, session] of this.sessions) {
sessions.push({
sessionId,
cwd: session.cwd,
adapter: 'claude',
permissionMode: session.permissionMode,
lastActivity: session.lastActivity || null,
hasClients: false,
hasDesktop: !!(session.lastActivity && (Date.now() - session.lastActivity < 120000)),
isNonInteractive: session.isNonInteractive || false,
firstPrompt: session.firstPrompt || null,
});
}
return sessions;
}
isProcessing(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
return !!(session?.isProcessing);
}
private _startSessionCleanup(): void {
this._cleanupInterval = setInterval(async () => {
const windows = await tmuxManager.listWindows();
const liveWindowIds = new Set(windows.map(w => w.id));
for (const [sessionId, session] of this.sessions) {
if (!liveWindowIds.has(session.windowId)) {
console.log(`[tmux] Stale session ${sessionId} — tmux window gone, cleaning up`);
this._teardownSession(session);
this.sessions.delete(sessionId);
this.emit('session-ended', sessionId);
}
}
if (this.sessions.size > 10) {
const sorted = [...this.sessions.entries()]
.sort((a, b) => (b[1].lastActivity || 0) - (a[1].lastActivity || 0));
for (const [id] of sorted.slice(10)) {
const s = this.sessions.get(id);
if (s) this._teardownSession(s);
this.sessions.delete(id);
this.emit('session-ended', id);
}
}
}, 60000);
// Don't keep the process alive just for cleanup — allows hooks-cli
// and other short-lived consumers to exit naturally after their work.
this._cleanupInterval.unref();
}
// === Helpers ===
private _createSession(windowId: string, cwd: string, cliSessionId: string, permissionMode: string): SessionState {
return {
windowId,
monitor: null,
watcher: null,
parser: null,
cwd,
cliSessionId,
permissionMode,
lastActivity: Date.now(),
firstPrompt: null,
isProcessing: false,
isNonInteractive: false,
_interactiveChecked: false,
_promptSenderClientId: null,
_modeTransitionDeadline: 0,
_watcherPending: false,
};
}
private _teardownSession(session: SessionState): void {
if (session.monitor) { session.monitor.stop(); session.monitor = null; }
if (session.watcher) { session.watcher.stop(); session.watcher = null; session.parser = null; }
}
async destroy(): Promise<void> {
if (this._cleanupInterval) {
clearInterval(this._cleanupInterval);
this._cleanupInterval = null;
}
for (const [, session] of this.sessions) {
this._teardownSession(session);
}
this.sessions.clear();
await tmuxManager.killSession();
}
// === Internal ===
private _startMonitor(sessionId: string, windowId: string): void {
const monitor = new PaneMonitor(windowId);
monitor.onThinking((thinking) => {
this.emit('thinking', sessionId, thinking);
});
monitor.onStreamingText((text) => {
this.emit('streaming-text', sessionId, text);
});
monitor.start();
const session = this.sessions.get(sessionId);
if (session) session.monitor = monitor;
}
private async _ensureWatcher(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session || session.watcher || session._watcherPending) return;
session._watcherPending = true;
const cliId = sessionId;
// Construct path directly (we know the UUID and cwd)
let jsonlPath: string | null = null;
if (session.cwd && cliId) {
const encoded = encodeDirName(session.cwd);
const directPath = join(PROJECTS_DIR, encoded, `${cliId}.jsonl`);
// Wait for file to appear (Claude creates it on first write)
// First 25 iterations at 200ms (5s), then 1s intervals for remaining time
for (let i = 0; i < 50; i++) {
try {
await stat(directPath);
jsonlPath = directPath;
break;
} catch {
await new Promise<void>(r => setTimeout(r, i < 25 ? 200 : 1000));
}
}
}
// Fallback: search all project dirs
if (!jsonlPath) jsonlPath = await this._findJsonlPath(cliId);
if (!jsonlPath) {
session._watcherPending = false; // Allow retry
return;
}
const parser = new TranscriptParser();
const watcher = new JsonlWatcher(jsonlPath);
watcher.onNewEntries((entries) => {
const { messages, interrupted } = parser.parse(entries as JsonlEntry[]);
if (messages.length > 0) {
// Capture first user prompt for active sessions list
if (!session.firstPrompt) {
const userMsg = messages.find(m => m.role === 'user');
if (userMsg) session.firstPrompt = (extractText(userMsg.content) || '').substring(0, 200);
}
// Tag user messages with sender's client ID so only the sender skips (dedup)
for (const msg of messages) {
if (msg.role === 'user' && session._promptSenderClientId) {
msg.senderClientId = session._promptSenderClientId;
session._promptSenderClientId = null;
}
}
this.emit('new-messages', sessionId, messages);
}
if (interrupted) {
this.emit('session-idle', sessionId);
}
const tools = parser.getPendingTools();
if (tools.size > 0) {
this.emit('tool-updates', sessionId, Object.fromEntries(tools));
}
});
watcher.start({ skipExisting: true });
session.watcher = watcher;
session.parser = parser;
session._watcherPending = false;
// Backfill firstPrompt from JSONL header (handles race where watcher
// starts after first user message was already written)
if (!session.firstPrompt && jsonlPath) {
try {
const { firstPrompt } = await parseSessionHeader(jsonlPath, sessionId);
if (firstPrompt) session.firstPrompt = firstPrompt;
} catch {}
}
}
private async _findJsonlPath(sessionId: string): Promise<string | null> {
try {
const dirs = await readdir(PROJECTS_DIR);
for (const dir of dirs) {
const filePath = join(PROJECTS_DIR, dir, `${sessionId}.jsonl`);
try {
await stat(filePath);
return filePath;
} catch {}
}
} catch {}
return null;
}
private async _findWindowForSession(sessionId: string, windowList?: TmuxWindow[]): Promise<string | null> {
const windows = windowList || await tmuxManager.listWindows();
// Search tmux windows by sessionId (window name = CLI UUID)
const match = windows.find(w => w.name === sessionId);
return match?.id || null;
}
private async _detectNonInteractive(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session || session._interactiveChecked) return;
session._interactiveChecked = true;
try {
const content = await tmuxManager.capturePane(session.windowId);
if (content.includes('claude -p ') || content.includes('claude --print')) {
session.isNonInteractive = true;
console.log(`[tmux] Session ${sessionId} detected as non-interactive (claude -p)`);
}
} catch {}
}
private async _windowExists(windowId: string, windowList?: TmuxWindow[]): Promise<boolean> {
const windows = windowList || await tmuxManager.listWindows();
return windows.some(w => w.id === windowId);
}
private async _waitForReady(windowId: string, timeoutMs: number = 30000): Promise<void> {
const start = Date.now();
let attempt = 0;
while (Date.now() - start < timeoutMs) {
attempt++;
try {
const content = await tmuxManager.capturePane(windowId);
const lines = content.split('\n');
const hasPrompt = lines.some(l => /^\s*/.test(l));
const lineCount = lines.filter(l => l.trim()).length;
if (attempt <= 3 || attempt % 5 === 0) {
console.log(`[adapter] waitForReady #${attempt}: window=${windowId} prompt=${hasPrompt} lines=${lineCount}`);
}
if (hasPrompt && lineCount >= 3) {
console.log(`[adapter] CLI ready for ${windowId} in ${Date.now() - start}ms`);
await new Promise<void>(r => setTimeout(r, 300));
return;
}
} catch (err) {
console.log(`[adapter] waitForReady #${attempt}: ERROR ${(err as Error).message}`);
}
await new Promise<void>(r => setTimeout(r, 1000));
}
console.warn(`[adapter] CLI ready timeout for ${windowId} after ${attempt} attempts`);
}
}
export const tmuxAdapter = new TmuxAdapter();
+200
View File
@@ -0,0 +1,200 @@
import { extractText, isSystemMessage, extractPlanContent, isNoResponseMessage, extractSubTools } from './message-utils.js';
import type { JsonlEntry, ContentBlock, SubToolBlock } from './message-utils.js';
/** Pending tool tracking entry */
export interface PendingTool {
toolUseId: string;
name: string;
input: Record<string, unknown>;
status: 'running' | 'success' | 'error';
result: ContentBlock | null;
parentToolUseId?: string;
}
/** Parsed message from transcript */
export interface ParsedMessage {
id: string;
role: 'user' | 'assistant' | 'plan';
content: ContentBlock[] | string;
senderClientId?: string | null;
adapter?: string;
}
/** Result of parse() */
export interface ParseResult {
messages: ParsedMessage[];
interrupted: boolean;
}
export class TranscriptParser {
pendingTools: Map<string, PendingTool>; // tool_use_id → { name, input, status }
private _pendingSubTools: Map<string, SubToolBlock[]>; // parentToolUseId → [tool_use blocks with parent_tool_use_id]
private _msgIndex: number = 0;
constructor() {
this.pendingTools = new Map();
this._pendingSubTools = new Map();
}
/**
* Parse new JSONL entries into frontend-ready messages.
* Only processes user/assistant type entries.
* Returns array of { role, content, tools? }
*/
parse(entries: JsonlEntry[]): ParseResult {
// NOTE: Do NOT reset _msgIndex here — parse() is called incrementally via
// watcher.onNewEntries(). Resetting would restart IDs at msg-0, causing
// React key collisions. _msgIndex accumulates across incremental batches.
const messages: ParsedMessage[] = [];
let interrupted = false;
for (const entry of entries) {
// Process agent_progress entries for sub-tool tracking
if (entry.type === 'progress' && entry.data?.type === 'agent_progress') {
this._processAgentProgress(entry);
continue;
}
if (!entry.message) continue;
// Detect user interruption marker
if (!interrupted && entry.type === 'user') {
const text = extractText(entry.message.content);
if (text.includes('[Request interrupted by user')) {
interrupted = true;
}
}
if (entry.type === 'user') {
const msg = this._parseUserEntry(entry);
if (msg) messages.push(msg);
} else if (entry.type === 'assistant') {
const msg = this._parseAssistantEntry(entry);
if (msg) messages.push(msg);
}
}
// Inject accumulated sub-tool blocks into assistant messages containing their parent Agent tool
// This handles history load and same-batch scenarios where Agent + progress arrive together
for (const msg of messages) {
if (msg.role !== 'assistant' || !Array.isArray(msg.content)) continue;
for (const block of msg.content) {
if (block.type !== 'tool_use') continue;
const subTools = this._pendingSubTools.get(block.id!);
if (subTools && subTools.length > 0) {
(msg.content as ContentBlock[]).push(...(subTools as unknown as ContentBlock[]));
this._pendingSubTools.delete(block.id!);
}
}
}
return { messages, interrupted };
}
/** Get current pending tool statuses (only running tools — completed sub-tools are excluded) */
getPendingTools(): Map<string, PendingTool> {
const filtered = new Map<string, PendingTool>();
for (const [id, tool] of this.pendingTools) {
if (tool.status === 'running') filtered.set(id, tool);
}
return filtered;
}
private _parseUserEntry(entry: JsonlEntry): ParsedMessage | null {
const content = entry.message!.content;
const text = extractText(content);
// Skip system/CLI messages
if (isSystemMessage(text, content)) return null;
// "Implement the following plan:" → plan type
const planBody = extractPlanContent(text);
if (planBody !== null) {
return { id: `msg-${this._msgIndex++}`, role: 'plan', content: planBody, adapter: 'claude' };
}
// Process tool_result blocks (pair with pending tool_use)
if (Array.isArray(content)) {
let hasToolResult = false;
for (const block of content) {
if (block.type === 'tool_result' && block.tool_use_id) {
hasToolResult = true;
const pending = this.pendingTools.get(block.tool_use_id);
if (pending) {
pending.status = block.is_error ? 'error' : 'success';
pending.result = block;
this.pendingTools.delete(block.tool_use_id);
}
}
}
// If message is ONLY tool results, don't emit as chat message
if (hasToolResult && !text.trim()) return null;
}
// Normal user message — normalize content to array format
const userContent: ContentBlock[] = typeof content === 'string'
? [{ type: 'text', text: content }]
: Array.isArray(content)
? content
: [{ type: 'text', text: String(content) }];
return { id: `msg-${this._msgIndex++}`, role: 'user', content: userContent, adapter: 'claude' };
}
private _parseAssistantEntry(entry: JsonlEntry): ParsedMessage | null {
const content = entry.message?.content || entry.content;
if (!content) return null;
// Track pending tool_use blocks
if (Array.isArray(content)) {
for (const block of content as ContentBlock[]) {
if (block.type === 'tool_use') {
this.pendingTools.set(block.id!, {
toolUseId: block.id!,
name: block.name!,
input: block.input as Record<string, unknown>,
status: 'running',
result: null,
});
}
}
}
// Skip "No response requested" type messages
const text = extractText(content);
if (isNoResponseMessage(text)) return null;
// Return content array directly (not the message wrapper)
const asstContent: ContentBlock[] = Array.isArray(content) ? content as ContentBlock[] : [{ type: 'text', text: String(content) }];
return { id: `msg-${this._msgIndex++}`, role: 'assistant', content: asstContent, adapter: 'claude' };
}
private _processAgentProgress(entry: JsonlEntry): void {
const result = extractSubTools(entry);
if (result) {
for (const subTool of result.subTools) {
// Track in pendingTools with parent reference
this.pendingTools.set(subTool.id, {
toolUseId: subTool.id, name: subTool.name, input: subTool.input,
status: 'running', result: null, parentToolUseId: result.parentId,
});
}
// Accumulate for injection into parent message content (same-batch / history)
if (!this._pendingSubTools.has(result.parentId)) this._pendingSubTools.set(result.parentId, []);
this._pendingSubTools.get(result.parentId)!.push(...result.subTools);
}
// Process tool_result entries (update status of pending sub-tools)
const msg = entry.data?.message?.message;
if (msg?.role === 'user' && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type !== 'tool_result' || !block.tool_use_id) continue;
const pending = this.pendingTools.get(block.tool_use_id);
if (pending) {
pending.status = block.is_error ? 'error' : 'success';
pending.result = block;
}
}
}
}
}