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:
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
// server/adapters/codex/codex-tmux-adapter.ts
|
||||
//
|
||||
// Session lifecycle management for Codex CLI sessions running in tmux.
|
||||
//
|
||||
// Key difference from Claude's TmuxAdapter:
|
||||
// - Claude has many hook events (PreToolUse, PostToolUse, etc.) for tool lifecycle
|
||||
// - Codex only has 3 hooks: SessionStart, UserPromptSubmit, Stop
|
||||
// - All tool events come from JSONL watching (via CodexTranscriptParser)
|
||||
// - JSONL watcher starts when SessionStart hook fires (provides transcript_path)
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { tmuxManager } from '../shared/tmux-manager.js';
|
||||
import { CodexPaneMonitor } from './pane-monitor.js';
|
||||
import { JsonlWatcher } from '../../stores/jsonl-watcher.js';
|
||||
import { CodexTranscriptParser } from './transcript-parser.js';
|
||||
import type { CodexJsonlEntry } from './transcript-parser.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 { readFile } from 'fs/promises';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Hook body payload from the Codex CLI */
|
||||
export interface CodexHookBody {
|
||||
session_id: string;
|
||||
cwd: string;
|
||||
model: string;
|
||||
permission_mode: string;
|
||||
source: string; // 'startup' | 'resume' | 'clear'
|
||||
transcript_path: string | null;
|
||||
hook_event_name: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Internal session state for a managed tmux session */
|
||||
export interface CodexSessionState {
|
||||
windowId: string;
|
||||
monitor: CodexPaneMonitor | null;
|
||||
watcher: JsonlWatcher | null;
|
||||
parser: CodexTranscriptParser | null;
|
||||
cwd: string;
|
||||
cliSessionId: string; // UUID from hook session_id
|
||||
transcriptPath: string | null; // from SessionStart hook — path to JSONL file
|
||||
approvalPolicy: string; // 'on-request', 'never', 'untrusted'
|
||||
lastActivity: number;
|
||||
firstPrompt: string | null;
|
||||
isProcessing: boolean;
|
||||
_promptSenderClientId: string | null;
|
||||
_watcherPending: boolean; // true until SessionStart hook provides transcript_path
|
||||
_matchRetryTimer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
/** Hook body with timestamp for age-based cleanup */
|
||||
type PendingHookBody = CodexHookBody & { _storedAt: number };
|
||||
|
||||
/** Resolved session context from _resolveAndTouch */
|
||||
interface ResolvedContext {
|
||||
sessionId: string;
|
||||
session: CodexSessionState | undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CodexTmuxAdapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* CodexTmuxAdapter — manages Codex CLI sessions via tmux.
|
||||
*
|
||||
* Three channels provide events to the SessionManager:
|
||||
* 1. HTTP Hooks (lifecycle): SessionStart, UserPromptSubmit, Stop
|
||||
* 2. JSONL Watcher (messages + tools): tool-start, tool-done, new-messages, status-update
|
||||
* 3. PaneMonitor (ephemeral): streaming-text, thinking, approval-prompt
|
||||
*
|
||||
* Events emitted:
|
||||
* streaming-text(sessionId, text)
|
||||
* thinking(sessionId, { text, detail })
|
||||
* tool-start(sessionId, { toolId, toolName, input })
|
||||
* tool-done(sessionId, { toolId, toolName, result })
|
||||
* tool-updates(sessionId, toolsMap)
|
||||
* new-messages(sessionId, messages[])
|
||||
* session-idle(sessionId)
|
||||
* processing-started(sessionId)
|
||||
* status-update(sessionId, { contextPercent, model, cost })
|
||||
* approval-prompt(sessionId, { command, explanation })
|
||||
* session-ended(sessionId)
|
||||
*/
|
||||
export class CodexTmuxAdapter extends EventEmitter {
|
||||
// sessionId (CLI UUID) -> session state
|
||||
sessions: Map<string, CodexSessionState>;
|
||||
// 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;
|
||||
private _pendingHookBodies: Map<string, PendingHookBody> = new Map();
|
||||
|
||||
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 }> {
|
||||
const parts = ['codex', '--no-alt-screen', '-C', cwd];
|
||||
const mode = options.permissionMode;
|
||||
this._appendPermissionFlags(parts, mode);
|
||||
if (options.model) parts.push('-m', options.model);
|
||||
if (options.effort) parts.push('-c', `model_reasoning_effort=${options.effort}`);
|
||||
|
||||
const tempName = `codex-${Date.now()}`;
|
||||
const windowId = await tmuxManager.createWindow(tempName, cwd, parts.join(' '));
|
||||
|
||||
// Register session BEFORE _waitForReady — SessionStart hook fires during
|
||||
// CLI startup and needs to find this session in the Map for matching.
|
||||
const tempKey = tempName;
|
||||
const approvalPolicy = mode || 'default';
|
||||
this.sessions.set(tempKey, this._createSession(windowId, cwd, '', approvalPolicy));
|
||||
|
||||
await this._waitForReady(windowId);
|
||||
|
||||
// After _waitForReady, SessionStart hook may have fired and rekeyed
|
||||
// the session from tempKey to the real CLI UUID. Return the current key.
|
||||
// (Currently Codex fires SessionStart after first prompt, so rekey doesn't
|
||||
// happen here — but this guards against future CLI timing changes.)
|
||||
let finalId = tempKey;
|
||||
if (!this.sessions.has(tempKey)) {
|
||||
const rekeyed = [...this.sessions.entries()].find(([, s]) => s.windowId === windowId)?.[0];
|
||||
if (rekeyed) {
|
||||
finalId = rekeyed;
|
||||
} else {
|
||||
console.warn(`[codex-tmux] Session ${tempKey} vanished during startup (windowId=${windowId})`);
|
||||
}
|
||||
}
|
||||
|
||||
this._startMonitor(finalId, windowId);
|
||||
|
||||
return { sessionId: finalId };
|
||||
}
|
||||
|
||||
async resumeSession(sessionId: string, cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
const codexUuid = session?.cliSessionId || sessionId;
|
||||
const mode = options.permissionMode || session?.approvalPolicy || 'default';
|
||||
const approvalPolicy = mode;
|
||||
|
||||
// Check if tmux window still alive
|
||||
if (session) {
|
||||
const windows = await tmuxManager.listWindows();
|
||||
if (windows.some(w => w.id === session.windowId)) {
|
||||
if (!session.monitor) this._startMonitor(sessionId, session.windowId);
|
||||
session.approvalPolicy = approvalPolicy;
|
||||
session.lastActivity = Date.now();
|
||||
return { sessionId };
|
||||
}
|
||||
// Window gone — teardown old
|
||||
this._teardownSession(session);
|
||||
}
|
||||
|
||||
const cliUuid = codexUuid; // CLI UUID for `codex resume <UUID>`
|
||||
const parts = ['codex', 'resume', cliUuid, '--no-alt-screen', '-C', cwd];
|
||||
this._appendPermissionFlags(parts, mode);
|
||||
if (options.model) parts.push('-m', options.model);
|
||||
if (options.effort) parts.push('-c', `model_reasoning_effort=${options.effort}`);
|
||||
|
||||
const newSessionId = codexUuid; // Key by CLI UUID
|
||||
const windowId = await tmuxManager.createWindow(codexUuid, cwd, parts.join(' '));
|
||||
|
||||
// Register before _waitForReady — same pattern as startSession
|
||||
if (session) {
|
||||
// Session exists under old key — move to new key (may be same if already CLI UUID)
|
||||
if (sessionId !== newSessionId) this.sessions.delete(sessionId);
|
||||
session.windowId = windowId;
|
||||
session.lastActivity = Date.now();
|
||||
session.approvalPolicy = approvalPolicy;
|
||||
session._watcherPending = true;
|
||||
session.transcriptPath = null;
|
||||
session.watcher = null;
|
||||
session.parser = null;
|
||||
this.sessions.set(newSessionId, session);
|
||||
} else {
|
||||
this.sessions.set(newSessionId, this._createSession(windowId, cwd, cliUuid, approvalPolicy));
|
||||
}
|
||||
|
||||
await this._waitForReady(windowId);
|
||||
|
||||
this._startMonitor(newSessionId, windowId);
|
||||
return { sessionId: newSessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle Plan Mode ↔ current mode via Shift+Tab.
|
||||
* Codex only supports 2-mode toggle (not Claude's 4-mode cycle).
|
||||
*/
|
||||
async switchPermissionMode(sessionId: string, targetMode: string): Promise<boolean> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return false;
|
||||
// One Shift+Tab press toggles Plan ↔ current
|
||||
await tmuxManager.sendControl(session.windowId, 'BTab');
|
||||
// Update local state — toggle is deterministic (frontend sends correct target)
|
||||
session.approvalPolicy = targetMode;
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
session.isProcessing = true;
|
||||
|
||||
// Restart pane monitor if it was stopped
|
||||
if (!session.monitor) {
|
||||
this._startMonitor(sessionId, session.windowId);
|
||||
}
|
||||
|
||||
if (isLargeContent(text)) {
|
||||
// Large/multiline content: replace newlines with literal \\n so Codex TUI
|
||||
// treats it as one message, then use pasteBuffer for speed.
|
||||
const singleLine = text.replace(/\n/g, '\\n');
|
||||
|
||||
// Codex TUI shows placeholder text on fresh sessions. pasteBuffer appends
|
||||
// to the placeholder, truncating the first ~20 chars. Fix: if content starts
|
||||
// with CLAWTAP_REF marker, send it via sendKeys first (clears placeholder),
|
||||
// then pasteBuffer the rest.
|
||||
const markerMatch = singleLine.match(/^\[CLAWTAP_REF:[^\]]+\]/);
|
||||
if (markerMatch) {
|
||||
const marker = markerMatch[0];
|
||||
const rest = singleLine.substring(marker.length);
|
||||
await tmuxManager.sendKeys(session.windowId, marker, false);
|
||||
await new Promise<void>(r => setTimeout(r, 200));
|
||||
if (rest) {
|
||||
await tmuxManager.pasteBuffer(session.windowId, rest, false);
|
||||
}
|
||||
} else {
|
||||
await tmuxManager.pasteBuffer(session.windowId, singleLine, false);
|
||||
}
|
||||
await new Promise<void>(r => setTimeout(r, 300));
|
||||
await tmuxManager.sendControl(session.windowId, 'Enter');
|
||||
} else {
|
||||
// Short text: send character-by-character via sendKeys
|
||||
await tmuxManager.sendKeys(session.windowId, text, false);
|
||||
await new Promise<void>(r => setTimeout(r, 200));
|
||||
await tmuxManager.sendControl(session.windowId, 'Enter');
|
||||
}
|
||||
|
||||
// If there are pending hook bodies waiting for marker matching, try now
|
||||
if (this._pendingHookBodies.size > 0 && session._watcherPending) {
|
||||
this._tryMatchPending(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
async switchModel(sessionId: string, model: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
await tmuxManager.sendKeys(session.windowId, `/model ${model}`, false);
|
||||
await new Promise<void>(r => setTimeout(r, 200));
|
||||
await tmuxManager.sendControl(session.windowId, 'Enter');
|
||||
}
|
||||
|
||||
async interrupt(sessionId: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
await tmuxManager.sendControl(session.windowId, 'C-c');
|
||||
session.isProcessing = false;
|
||||
if (session.monitor) {
|
||||
session.monitor.stop();
|
||||
session.monitor = null;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// === Hook Handlers ===
|
||||
|
||||
/**
|
||||
* Handle the SessionStart hook from Codex CLI.
|
||||
*
|
||||
* This is the moment we learn the transcript_path and can start the JSONL watcher.
|
||||
* It may also be the first time we see the Codex UUID for sessions started via startSession().
|
||||
*/
|
||||
handleSessionStart(body: CodexHookBody): void {
|
||||
const codexUuid = body.session_id;
|
||||
if (!codexUuid) return;
|
||||
|
||||
// 1. Already managed (resume, or session with known UUID)
|
||||
if (this.sessions.has(codexUuid)) {
|
||||
this._applySessionStartBody(codexUuid, body);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Find pending sessions (_watcherPending === true)
|
||||
const pending = [...this.sessions.entries()].filter(([, s]) => s._watcherPending);
|
||||
if (pending.length === 0) return; // Not our session
|
||||
|
||||
// 3. Exactly 1 pending → direct match (no marker needed)
|
||||
if (pending.length === 1) {
|
||||
const [tempKey] = pending[0];
|
||||
console.log(`[codex-tmux] Direct match: ${tempKey} → ${codexUuid}`);
|
||||
this._rekeyAndRename(tempKey, codexUuid);
|
||||
this._applySessionStartBody(codexUuid, body);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Multiple pending → store, wait for sendMessage to disambiguate via marker
|
||||
this._pendingHookBodies.set(codexUuid, { ...body, _storedAt: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after sendMessage when _pendingHookBodies has entries.
|
||||
* Reads each pending hook body's transcript_path to find the CLAWTAP_REF marker.
|
||||
*/
|
||||
private async _tryMatchPending(tempKey: string): Promise<void> {
|
||||
if (await this._scanPendingForMarker(tempKey)) return;
|
||||
|
||||
// Marker not found yet — Codex may still be writing. Retry once after 2s.
|
||||
const session = this.sessions.get(tempKey);
|
||||
if (!session) return;
|
||||
if (session._matchRetryTimer) clearTimeout(session._matchRetryTimer);
|
||||
session._matchRetryTimer = setTimeout(async () => {
|
||||
const s = this.sessions.get(tempKey);
|
||||
if (!s || !s._watcherPending || !this._pendingHookBodies.size) return;
|
||||
await this._scanPendingForMarker(tempKey);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/** Scan _pendingHookBodies for a transcript containing CLAWTAP_REF:{tempKey}. */
|
||||
private async _scanPendingForMarker(tempKey: string): Promise<boolean> {
|
||||
for (const [uuid, body] of this._pendingHookBodies) {
|
||||
if (!body.transcript_path) continue;
|
||||
try {
|
||||
const content = await readFile(body.transcript_path, 'utf8');
|
||||
if (!content.includes(`CLAWTAP_REF:${tempKey}`)) continue;
|
||||
console.log(`[codex-tmux] Marker match: ${tempKey} → ${uuid}`);
|
||||
this._pendingHookBodies.delete(uuid);
|
||||
this._rekeyAndRename(tempKey, uuid);
|
||||
this._applySessionStartBody(uuid, body);
|
||||
return true;
|
||||
} catch { continue; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Apply hook body state and start watcher — shared by all handleSessionStart branches */
|
||||
private _applySessionStartBody(sessionId: string, body: CodexHookBody): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
if (!session.cliSessionId) session.cliSessionId = body.session_id;
|
||||
if (body.cwd) session.cwd = body.cwd;
|
||||
if (body.permission_mode) session.approvalPolicy = body.permission_mode;
|
||||
session.lastActivity = Date.now();
|
||||
if (body.transcript_path && !session.transcriptPath) {
|
||||
session.transcriptPath = body.transcript_path;
|
||||
}
|
||||
|
||||
// Start JSONL watcher if we have a transcript path and watcher isn't already running
|
||||
if (session.transcriptPath && !session.watcher) {
|
||||
const skipExisting = session.isProcessing !== false;
|
||||
this._startWatcher(sessionId, session, skipExisting);
|
||||
}
|
||||
session._watcherPending = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the UserPromptSubmit hook from Codex CLI.
|
||||
*/
|
||||
handleUserPromptSubmit(body: CodexHookBody): void {
|
||||
const ctx = this._resolveAndTouch(body);
|
||||
if (!ctx) return;
|
||||
|
||||
const { sessionId, session } = ctx;
|
||||
if (session) {
|
||||
session.isProcessing = true;
|
||||
if (!session.monitor && session.windowId) {
|
||||
this._startMonitor(sessionId, session.windowId);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('processing-started', sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Stop hook from Codex CLI.
|
||||
*/
|
||||
handleStop(body: CodexHookBody): void {
|
||||
const ctx = this._resolveAndTouch(body);
|
||||
if (!ctx) return;
|
||||
|
||||
const { sessionId, session } = ctx;
|
||||
if (session) {
|
||||
session.isProcessing = false;
|
||||
if (session.monitor) {
|
||||
session.monitor.stop();
|
||||
session.monitor = null;
|
||||
}
|
||||
// Flush JSONL watcher to get final entries
|
||||
if (session.watcher) {
|
||||
session.watcher.pollNow();
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('session-idle', sessionId);
|
||||
this._permissions.dismissAll(sessionId);
|
||||
}
|
||||
|
||||
// === JSONL Watcher ===
|
||||
|
||||
/**
|
||||
* Process raw JSONL entries through the transcript parser and emit events.
|
||||
*/
|
||||
private _processWatcherEntries(sessionId: string, rawEntries: unknown[]): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session?.parser) return;
|
||||
|
||||
const entries = rawEntries as CodexJsonlEntry[];
|
||||
const result = session.parser.processNewEntries(entries);
|
||||
|
||||
// Emit tool lifecycle events
|
||||
for (const ts of result.toolStarts) {
|
||||
this.emit('tool-start', sessionId, ts);
|
||||
}
|
||||
for (const td of result.toolDones) {
|
||||
this.emit('tool-done', sessionId, td);
|
||||
}
|
||||
|
||||
// Emit messages
|
||||
if (result.messages.length > 0) {
|
||||
// Capture first user prompt for active sessions list
|
||||
if (!session.firstPrompt) {
|
||||
const userMsg = result.messages.find(m => m.role === 'user');
|
||||
if (userMsg) {
|
||||
const text = userMsg.content
|
||||
.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
|
||||
.map(c => c.text)
|
||||
.join('\n');
|
||||
if (text) {
|
||||
const stripped = text.replace(/^(?:\[CLAWTAP_REF:[^\]]+\]|\d+\])(?:\\n|\n)?/, '');
|
||||
session.firstPrompt = stripped.substring(0, 200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tag user messages with sender's client ID so only the sender skips (dedup)
|
||||
for (const msg of result.messages) {
|
||||
if (msg.role === 'user' && session._promptSenderClientId) {
|
||||
msg.senderClientId = session._promptSenderClientId;
|
||||
session._promptSenderClientId = null;
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('new-messages', sessionId, result.messages);
|
||||
}
|
||||
|
||||
// Emit tool updates map
|
||||
if (result.toolUpdates) {
|
||||
this.emit('tool-updates', sessionId, result.toolUpdates);
|
||||
}
|
||||
|
||||
// Emit status update
|
||||
if (result.statusUpdate) {
|
||||
this.emit('status-update', sessionId, result.statusUpdate);
|
||||
}
|
||||
|
||||
// Handle turn completion from JSONL (task_complete/turn_aborted).
|
||||
// Only emit if session is still processing — prevents duplicate session-idle
|
||||
// when the Stop hook already fired (hook sets isProcessing=false first).
|
||||
if (result.turnComplete && session.isProcessing) {
|
||||
session.isProcessing = false;
|
||||
if (session.monitor) {
|
||||
session.monitor.stop();
|
||||
session.monitor = null;
|
||||
}
|
||||
this.emit('session-idle', sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// === Query Methods ===
|
||||
|
||||
getSession(sessionId: string): CodexSessionState | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
getActiveSessions(): ActiveSessionInfo[] {
|
||||
const result: ActiveSessionInfo[] = [];
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
result.push({
|
||||
sessionId,
|
||||
cwd: session.cwd,
|
||||
adapter: 'codex',
|
||||
permissionMode: session.approvalPolicy,
|
||||
lastActivity: session.lastActivity || null,
|
||||
hasClients: this._clientChecker ? this._clientChecker(sessionId) : false,
|
||||
hasDesktop: !!(session.lastActivity && (Date.now() - session.lastActivity < 120_000)),
|
||||
isNonInteractive: false, // Codex doesn't have non-interactive mode detection
|
||||
firstPrompt: session.firstPrompt || null,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async hasActiveWindow(sessionId: string): Promise<boolean> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return false;
|
||||
const windows = await tmuxManager.listWindows();
|
||||
return windows.some(w => w.id === session.windowId);
|
||||
}
|
||||
|
||||
isProcessing(sessionId: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
return !!(session?.isProcessing);
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
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;
|
||||
}
|
||||
|
||||
// === Permission Methods ===
|
||||
|
||||
respondPermission(requestId: string, behavior: PermissionBehavior): void {
|
||||
const pending = this._permissions.resolvePermission(requestId);
|
||||
if (!pending) return;
|
||||
|
||||
const session = this.sessions.get(pending.sessionId);
|
||||
if (!session) return;
|
||||
|
||||
// Codex approval via tmux keystroke
|
||||
if (behavior === 'allow' || behavior === 'allow_session') {
|
||||
// Send 'y' to approve
|
||||
tmuxManager.sendKeys(session.windowId, 'y', true).catch(() => {});
|
||||
} else {
|
||||
// Send 'n' to deny
|
||||
tmuxManager.sendKeys(session.windowId, 'n', true).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async respondQuestion(requestId: string, answer: string): Promise<void> {
|
||||
const pending = this._permissions.resolveQuestion(requestId);
|
||||
if (!pending) return;
|
||||
|
||||
const session = this.sessions.get(pending.sessionId);
|
||||
if (!session) return;
|
||||
|
||||
await tmuxManager.sendKeys(session.windowId, answer, true);
|
||||
}
|
||||
|
||||
/** Release all pending requests for a session (e.g., when Mobile disconnects). */
|
||||
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) {
|
||||
tmuxManager.sendKeys(session.windowId, 'y', true).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Cleanup ===
|
||||
|
||||
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 Helpers ===
|
||||
|
||||
/** Append the correct permission flags based on the permission mode string. */
|
||||
private _appendPermissionFlags(parts: string[], mode?: string): void {
|
||||
if (mode === 'bypassPermissions') {
|
||||
parts.push('--dangerously-bypass-approvals-and-sandbox');
|
||||
} else if (mode === 'fullAuto') {
|
||||
parts.push('--full-auto');
|
||||
} else if (mode === 'untrusted') {
|
||||
parts.push('-a', 'untrusted');
|
||||
} else {
|
||||
parts.push('-a', 'on-request');
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve hook body to internal session, touch lastActivity */
|
||||
private _resolveAndTouch(body: CodexHookBody): ResolvedContext | null {
|
||||
const sessionId = body.session_id;
|
||||
if (!sessionId || !this.sessions.has(sessionId)) return null;
|
||||
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) session.lastActivity = Date.now();
|
||||
|
||||
return { sessionId, session };
|
||||
}
|
||||
|
||||
private _createSession(
|
||||
windowId: string,
|
||||
cwd: string,
|
||||
cliSessionId: string,
|
||||
approvalPolicy: string,
|
||||
): CodexSessionState {
|
||||
return {
|
||||
windowId,
|
||||
monitor: null,
|
||||
watcher: null,
|
||||
parser: null,
|
||||
cwd,
|
||||
cliSessionId,
|
||||
transcriptPath: null,
|
||||
approvalPolicy,
|
||||
lastActivity: Date.now(),
|
||||
firstPrompt: null,
|
||||
isProcessing: false,
|
||||
_promptSenderClientId: null,
|
||||
_watcherPending: true,
|
||||
_matchRetryTimer: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Codex CLI to be ready (show the › prompt).
|
||||
* Polls tmux pane content until the prompt indicator appears.
|
||||
*/
|
||||
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');
|
||||
// Codex shows › as the input prompt
|
||||
const hasPrompt = lines.some(l => /^\s*›/.test(l));
|
||||
const lineCount = lines.filter(l => l.trim()).length;
|
||||
if (attempt <= 3 || attempt % 5 === 0) {
|
||||
console.log(`[codex-tmux] waitForReady #${attempt}: window=${windowId} prompt=${hasPrompt} lines=${lineCount}`);
|
||||
}
|
||||
if (hasPrompt && lineCount >= 3) {
|
||||
console.log(`[codex-tmux] CLI ready for ${windowId} in ${Date.now() - start}ms`);
|
||||
// Extra settle time for Codex TUI to fully render after prompt appears
|
||||
await new Promise<void>(r => setTimeout(r, 300));
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`[codex-tmux] waitForReady #${attempt}: ERROR ${(err as Error).message}`);
|
||||
}
|
||||
await new Promise<void>(r => setTimeout(r, 500));
|
||||
}
|
||||
console.warn(`[codex-tmux] Timed out waiting for CLI ready on ${windowId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-key a session from tempKey to the real CLI UUID and rename the tmux window.
|
||||
*/
|
||||
private _rekeyAndRename(tempKey: string, cliUuid: string): void {
|
||||
const session = this.sessions.get(tempKey);
|
||||
if (!session) return;
|
||||
session.cliSessionId = cliUuid;
|
||||
session._watcherPending = false;
|
||||
this.sessions.delete(tempKey);
|
||||
this.sessions.set(cliUuid, session);
|
||||
tmuxManager.renameWindow(session.windowId, cliUuid).catch(() => {});
|
||||
if (session.monitor) {
|
||||
(session.monitor as any).sessionId = cliUuid;
|
||||
}
|
||||
// Notify session-manager to re-register clients under the new key
|
||||
this.emit('session-rekeyed', tempKey, cliUuid);
|
||||
}
|
||||
|
||||
private _startMonitor(sessionId: string, windowId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
// Stop existing monitor if any
|
||||
if (session.monitor) {
|
||||
session.monitor.stop();
|
||||
}
|
||||
|
||||
const monitor = new CodexPaneMonitor(sessionId, windowId, tmuxManager, this);
|
||||
monitor.start();
|
||||
session.monitor = monitor;
|
||||
}
|
||||
|
||||
private _startWatcher(sessionId: string, session: CodexSessionState, skipExisting = true): void {
|
||||
if (!session.transcriptPath) return;
|
||||
if (session.watcher) return;
|
||||
|
||||
const parser = new CodexTranscriptParser();
|
||||
const watcher = new JsonlWatcher(session.transcriptPath);
|
||||
|
||||
watcher.onNewEntries((entries) => {
|
||||
this._processWatcherEntries(sessionId, entries);
|
||||
});
|
||||
|
||||
watcher.start({ skipExisting, fallbackIntervalMs: 1000 });
|
||||
session.watcher = watcher;
|
||||
session.parser = parser;
|
||||
session._watcherPending = false;
|
||||
}
|
||||
|
||||
private _teardownSession(session: CodexSessionState): void {
|
||||
if (session.monitor) {
|
||||
session.monitor.stop();
|
||||
session.monitor = null;
|
||||
}
|
||||
if (session.watcher) {
|
||||
session.watcher.stop();
|
||||
session.watcher = null;
|
||||
session.parser = null;
|
||||
}
|
||||
if (session._matchRetryTimer) {
|
||||
clearTimeout(session._matchRetryTimer);
|
||||
session._matchRetryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
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 (session.windowId && !liveWindowIds.has(session.windowId)) {
|
||||
console.log(`[codex-tmux] Stale session ${sessionId} — tmux window gone, cleaning up`);
|
||||
this._teardownSession(session);
|
||||
this.sessions.delete(sessionId);
|
||||
this.emit('session-ended', sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Cap at 10 managed sessions
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale pending hook bodies (age-based sweep)
|
||||
for (const [uuid, body] of this._pendingHookBodies) {
|
||||
const age = Date.now() - body._storedAt;
|
||||
if (age > 60_000) this._pendingHookBodies.delete(uuid);
|
||||
}
|
||||
}, 60_000);
|
||||
// Don't keep the process alive just for cleanup
|
||||
this._cleanupInterval.unref();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// server/adapters/codex/hook-config.ts
|
||||
//
|
||||
// Pure filesystem operations for Codex hook management.
|
||||
// Zero runtime dependencies — no EventEmitter, no tmux, no sessions.
|
||||
//
|
||||
// Key differences from Claude's hook-config:
|
||||
// - Hooks live in ~/.codex/hooks.json (dedicated file, not mixed with other settings)
|
||||
// - Only 3 hook events: SessionStart, UserPromptSubmit, Stop
|
||||
// - No statusLine wrapping (Codex has no statusLine hook)
|
||||
// - Additionally manages codex_hooks feature flag in ~/.codex/config.toml
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { parse as parseTOML, stringify as stringifyTOML } from 'smol-toml';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** 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;
|
||||
hookPath: string;
|
||||
}
|
||||
|
||||
/** The structure of Codex's hooks.json */
|
||||
interface CodexHooksFile {
|
||||
hooks?: Record<string, HookEntry[]>;
|
||||
}
|
||||
|
||||
export class CodexHookConfig {
|
||||
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 ~/.codex/hooks.json and enable feature flag in config.toml */
|
||||
install(): void {
|
||||
const port = this.port;
|
||||
const codexDir = join(homedir(), '.codex');
|
||||
const hooksPath = join(codexDir, 'hooks.json');
|
||||
const configTomlPath = join(codexDir, 'config.toml');
|
||||
|
||||
const { portTag, hookPath } = this._hookIdentifiers();
|
||||
const protocol = this.useHttps ? 'https' : 'http';
|
||||
const hookUrl = `${protocol}://localhost:${port}/api/hooks/codex`;
|
||||
const desiredHooks = this._buildDesiredHooks(hookUrl, hookPath);
|
||||
|
||||
try {
|
||||
mkdirSync(codexDir, { recursive: true });
|
||||
|
||||
// --- 1. Write hooks.json ---
|
||||
let existing: CodexHooksFile = {};
|
||||
try { existing = JSON.parse(readFileSync(hooksPath, 'utf-8')) as CodexHooksFile; } catch {}
|
||||
|
||||
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, hookPath));
|
||||
existing.hooks[event] = [...filtered, ...configs];
|
||||
}
|
||||
|
||||
writeFileSync(hooksPath, JSON.stringify(existing, null, 2));
|
||||
console.log(`[hooks:codex] Auto-configured hooks in ${hooksPath}`);
|
||||
|
||||
// --- 2. Enable codex_hooks feature flag in config.toml ---
|
||||
this._setFeatureFlag(configTomlPath, true);
|
||||
} catch (err) {
|
||||
console.warn(`[hooks:codex] Failed to auto-configure hooks: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove ClawTap hooks from ~/.codex/hooks.json.
|
||||
* Optionally remove the codex_hooks feature flag from config.toml.
|
||||
*/
|
||||
uninstall(): void {
|
||||
const { portTag, hookPath } = this._hookIdentifiers();
|
||||
const codexDir = join(homedir(), '.codex');
|
||||
const hooksPath = join(codexDir, 'hooks.json');
|
||||
const configTomlPath = join(codexDir, 'config.toml');
|
||||
|
||||
try {
|
||||
const existing: CodexHooksFile = JSON.parse(readFileSync(hooksPath, 'utf-8')) as CodexHooksFile;
|
||||
|
||||
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, hookPath));
|
||||
|
||||
if (filtered.length === 0) {
|
||||
delete existing.hooks[key];
|
||||
} else {
|
||||
existing.hooks[key] = filtered;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(existing.hooks).length === 0) delete existing.hooks;
|
||||
}
|
||||
|
||||
writeFileSync(hooksPath, JSON.stringify(existing, null, 2));
|
||||
console.log(`[hooks:codex] Removed ClawTap hooks from ${hooksPath}`);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
// hooks.json doesn't exist — nothing to clean up
|
||||
} else {
|
||||
console.warn(`[hooks:codex] Failed to remove hooks: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the feature flag
|
||||
try {
|
||||
this._setFeatureFlag(join(homedir(), '.codex', 'config.toml'), false);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.warn(`[hooks:codex] Failed to update config.toml: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
private _hookIdentifiers(): HookIdentifiers {
|
||||
return {
|
||||
portTag: `:${this.port}/api/hooks/codex`,
|
||||
hookPath: join(__dirname, '..', '..', '..', 'bin', 'clawtap-hook'),
|
||||
};
|
||||
}
|
||||
|
||||
private _isOurHookEntry(entry: HookEntry, portTag: string, hookPath: string): boolean {
|
||||
const hooks = entry.hooks || [];
|
||||
return hooks.some(h =>
|
||||
(h.url && h.url.includes(portTag)) ||
|
||||
(h.command && (h.command === hookPath || h.command.includes(portTag)))
|
||||
);
|
||||
}
|
||||
|
||||
private _buildDesiredHooks(hookUrl: string, hookPath: string): Record<string, HookEntry[]> {
|
||||
// Fire-and-forget: read stdin, background curl, exit immediately.
|
||||
// Zero blocking — Codex never waits for ClawTap.
|
||||
// NOTE: No /dev/tcp port check — Codex executes hooks with zsh, which doesn't
|
||||
// support /dev/tcp (bash-only). curl's --connect-timeout handles the "not listening" case.
|
||||
const curlInsecure = this.useHttps ? ' -k' : '';
|
||||
const fireAndForget = (endpoint: string): string =>
|
||||
`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 }] }],
|
||||
Stop: [{ hooks: [{ type: 'command', command: fireAndForget('stop'), timeout: 2 }] }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-trust a directory in config.toml so Codex doesn't show an interactive
|
||||
* trust prompt that blocks _waitForReady.
|
||||
*/
|
||||
trustDirectory(dirPath: string): void {
|
||||
const configPath = join(homedir(), '.codex', 'config.toml');
|
||||
let config: Record<string, any> = {};
|
||||
|
||||
try {
|
||||
config = parseTOML(readFileSync(configPath, 'utf-8')) as Record<string, any>;
|
||||
} catch {
|
||||
if (existsSync(configPath)) return; // Corrupted file — don't touch
|
||||
}
|
||||
|
||||
if (!config.project) config.project = {};
|
||||
const projects = config.project as Record<string, any>;
|
||||
|
||||
// Already trusted
|
||||
if (projects[dirPath]?.trust_level === 'trusted') return;
|
||||
|
||||
// Add trust
|
||||
projects[dirPath] = { ...(projects[dirPath] || {}), trust_level: 'trusted' };
|
||||
|
||||
mkdirSync(dirname(configPath), { recursive: true });
|
||||
writeFileSync(configPath, stringifyTOML(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or remove the codex_hooks feature flag in config.toml.
|
||||
*
|
||||
* Uses smol-toml parser to safely modify the TOML file without corrupting
|
||||
* other sections (e.g., [project."..."] with paths containing special chars).
|
||||
*/
|
||||
private _setFeatureFlag(configPath: string, enable: boolean): void {
|
||||
let config: Record<string, any> = {};
|
||||
|
||||
// Parse existing config (if any)
|
||||
try {
|
||||
const content = readFileSync(configPath, 'utf-8');
|
||||
config = parseTOML(content) as Record<string, any>;
|
||||
} catch (err) {
|
||||
if (existsSync(configPath)) {
|
||||
// File exists but has invalid TOML — don't overwrite it
|
||||
console.warn(`[hooks:codex] Warning: ${configPath} has invalid TOML, skipping modification`);
|
||||
return;
|
||||
}
|
||||
// File doesn't exist — start fresh
|
||||
}
|
||||
|
||||
if (enable) {
|
||||
if (!config.features) config.features = {};
|
||||
(config.features as Record<string, any>).codex_hooks = true;
|
||||
} else {
|
||||
if (config.features && typeof config.features === 'object') {
|
||||
delete (config.features as Record<string, any>).codex_hooks;
|
||||
// Remove empty [features] section
|
||||
if (Object.keys(config.features as object).length === 0) {
|
||||
delete config.features;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only write if there's content or we're enabling
|
||||
if (enable || Object.keys(config).length > 0) {
|
||||
mkdirSync(dirname(configPath), { recursive: true });
|
||||
writeFileSync(configPath, stringifyTOML(config));
|
||||
console.log(`[hooks:codex] ${enable ? 'Enabled' : 'Disabled'} codex_hooks in ${configPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// server/adapters/codex/index.ts
|
||||
import { IAdapter } from '../interface.js';
|
||||
import type { DirectoryEntry, ActiveSessionInfo, MessagesResult, CachedStatus } from '../interface.js';
|
||||
import { CodexTmuxAdapter } from './codex-tmux-adapter.js';
|
||||
import type { CodexSessionState, CodexHookBody } from './codex-tmux-adapter.js';
|
||||
import { CodexHookConfig } from './hook-config.js';
|
||||
import {
|
||||
getSessions, getMessages, listDirectory,
|
||||
} 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';
|
||||
|
||||
|
||||
|
||||
const MODELS: ModelInfo[] = [
|
||||
{ value: 'gpt-5.4', label: 'GPT-5.4', contextWindow: 258400 },
|
||||
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', contextWindow: 258400 },
|
||||
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', contextWindow: 258400 },
|
||||
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex', contextWindow: 258400 },
|
||||
{ value: 'gpt-5.2', label: 'GPT-5.2', contextWindow: 258400 },
|
||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max', contextWindow: 258400 },
|
||||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex', contextWindow: 258400 },
|
||||
{ value: 'gpt-5.1', label: 'GPT-5.1', contextWindow: 258400 },
|
||||
{ value: 'gpt-5-codex', label: 'GPT-5 Codex', contextWindow: 258400 },
|
||||
{ value: 'gpt-5', label: 'GPT-5', contextWindow: 258400 },
|
||||
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini', contextWindow: 258400 },
|
||||
{ value: 'gpt-5-codex-mini', label: 'GPT-5 Codex Mini', contextWindow: 258400 },
|
||||
];
|
||||
|
||||
const PERMISSION_MODES: PermissionModeInfo[] = [
|
||||
{ value: 'default', label: 'Suggest' },
|
||||
{ value: 'fullAuto', label: 'Full Auto' },
|
||||
{ value: 'untrusted', label: 'Untrusted' },
|
||||
{ value: 'bypassPermissions', label: 'YOLO' },
|
||||
];
|
||||
|
||||
const EFFORT_LEVELS: EffortLevelInfo[] = [
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'xhigh', label: 'X-High' },
|
||||
];
|
||||
|
||||
export class CodexAdapter extends IAdapter {
|
||||
static id: string = 'codex';
|
||||
static displayName: string = 'Codex CLI';
|
||||
static command: string = 'codex';
|
||||
|
||||
private _tmux: CodexTmuxAdapter;
|
||||
private _hookConfig: CodexHookConfig;
|
||||
private _lastStatus: Map<string, CachedStatus>; // sessionId → { contextPercent, model, cost }
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._tmux = new CodexTmuxAdapter();
|
||||
this._hookConfig = new CodexHookConfig();
|
||||
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', 'session-rekeyed',
|
||||
];
|
||||
for (const event of events) {
|
||||
this._tmux.on(event, (...args: unknown[]) => this.emit(event, ...args));
|
||||
}
|
||||
|
||||
// Don't forward status-update blindly — deduplicate first
|
||||
this._tmux.on('status-update', (sessionId: string, status: any) => {
|
||||
const prev = this._lastStatus.get(sessionId);
|
||||
if (prev &&
|
||||
prev.contextPercent === status.contextPercent &&
|
||||
prev.model === status.model &&
|
||||
prev.cost === status.cost) return;
|
||||
this._lastStatus.set(sessionId, status);
|
||||
this.emit('status-update', sessionId, status);
|
||||
});
|
||||
|
||||
// Clean up status 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 Codex-specific hooks.
|
||||
* These are called by the Codex 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: CodexHookBody) => 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/codex
|
||||
|
||||
hookRoute(`${prefix}/session-start`, (body) => {
|
||||
this._tmux.handleSessionStart(body);
|
||||
});
|
||||
hookRoute(`${prefix}/user-prompt-submit`, (body) => {
|
||||
this._tmux.handleUserPromptSubmit(body);
|
||||
});
|
||||
hookRoute(`${prefix}/stop`, (body) => {
|
||||
this._tmux.handleStop(body);
|
||||
});
|
||||
}
|
||||
|
||||
setClientChecker(fn: (sessionId: string) => boolean): void {
|
||||
this._tmux.setClientChecker(fn);
|
||||
}
|
||||
|
||||
// Lifecycle — delegate to tmux adapter
|
||||
async startSession(cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> {
|
||||
try { this._hookConfig.trustDirectory(cwd); } catch {} // Auto-trust cwd to prevent interactive prompt
|
||||
return this._tmux.startSession(cwd, options);
|
||||
}
|
||||
async resumeSession(sid: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> {
|
||||
if (cwd) try { this._hookConfig.trustDirectory(cwd); } catch {}
|
||||
return this._tmux.resumeSession(sid, cwd, options);
|
||||
}
|
||||
async attachSession(_sid: string, _cwd: string, _options?: QueryOptions): Promise<{ sessionId: string }> { throw new Error('Codex does not support attach'); }
|
||||
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 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<MessagesResult> { 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): CodexSessionState | 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 'Effort'; }
|
||||
|
||||
getCapabilities(): AdapterCapabilities {
|
||||
return {
|
||||
supportsPlanMode: false,
|
||||
supportsPermissionModes: true,
|
||||
supportsInterrupt: true,
|
||||
supportsResume: true,
|
||||
supportsAttach: false,
|
||||
supportsStatusLine: true,
|
||||
supportsImages: true,
|
||||
supportsStreaming: true,
|
||||
maxContextWindow: 258_400,
|
||||
permissionModeType: 'toggle',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { createReadStream } from 'fs';
|
||||
import { createInterface } from 'readline';
|
||||
import { CodexTranscriptParser } from './transcript-parser.js';
|
||||
import type { CodexJsonlEntry } from './transcript-parser.js';
|
||||
import type { DirectoryEntry, MessagesResult } from '../interface.js';
|
||||
import type { SessionInfo } from '../../types/adapter.js';
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
export const CODEX_DIR: string = join(homedir(), '.codex');
|
||||
export const SESSIONS_DIR: string = join(CODEX_DIR, 'sessions');
|
||||
export const HISTORY_FILE: string = join(CODEX_DIR, 'history.jsonl');
|
||||
|
||||
// --- History index entry ---
|
||||
|
||||
interface HistoryEntry {
|
||||
session_id: string;
|
||||
ts: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/**
|
||||
* Scan ~/.codex/sessions/YYYY/MM/DD/ directories from newest to oldest.
|
||||
* Match filename containing the session UUID.
|
||||
* Filename pattern: rollout-YYYY-MM-DDTHH-MM-SS-{uuid}.jsonl
|
||||
*
|
||||
* Strategy: list year/month/day dirs in descending order so the newest
|
||||
* match is found first — most session lookups are for recent sessions.
|
||||
*/
|
||||
export async function findSessionFile(sessionId: string, sessionsDir?: string): Promise<string | null> {
|
||||
const baseDir = sessionsDir || SESSIONS_DIR;
|
||||
|
||||
let years: string[];
|
||||
try {
|
||||
years = await readdir(baseDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort descending (newest first)
|
||||
years.sort((a, b) => b.localeCompare(a));
|
||||
|
||||
for (const year of years) {
|
||||
const yearPath = join(baseDir, year);
|
||||
const yearStat = await stat(yearPath).catch(() => null);
|
||||
if (!yearStat?.isDirectory()) continue;
|
||||
|
||||
let months: string[];
|
||||
try {
|
||||
months = await readdir(yearPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
months.sort((a, b) => b.localeCompare(a));
|
||||
|
||||
for (const month of months) {
|
||||
const monthPath = join(yearPath, month);
|
||||
const monthStat = await stat(monthPath).catch(() => null);
|
||||
if (!monthStat?.isDirectory()) continue;
|
||||
|
||||
let days: string[];
|
||||
try {
|
||||
days = await readdir(monthPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
days.sort((a, b) => b.localeCompare(a));
|
||||
|
||||
for (const day of days) {
|
||||
const dayPath = join(monthPath, day);
|
||||
const dayStat = await stat(dayPath).catch(() => null);
|
||||
if (!dayStat?.isDirectory()) continue;
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = await readdir(dayPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look for a file that contains the session UUID
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.jsonl') && file.includes(sessionId)) {
|
||||
return join(dayPath, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Session meta parsing ---
|
||||
|
||||
interface SessionMeta {
|
||||
cwd: string | null;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
async function parseSessionMeta(filePath: string): Promise<SessionMeta> {
|
||||
const stream = createReadStream(filePath);
|
||||
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
||||
let cwd: string | null = null;
|
||||
let model: string | null = null;
|
||||
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry: CodexJsonlEntry = JSON.parse(line);
|
||||
if (entry.type === 'session_meta') {
|
||||
cwd = entry.payload?.cwd ?? null;
|
||||
model = entry.payload?.model_provider ?? entry.payload?.model ?? null;
|
||||
break;
|
||||
}
|
||||
// Also check turn_context for model info
|
||||
if (entry.type === 'turn_context' && entry.payload?.model) {
|
||||
model = entry.payload.model;
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
}
|
||||
|
||||
return { cwd, model };
|
||||
}
|
||||
|
||||
// --- Session Listing ---
|
||||
|
||||
export async function getSessions(dir?: string, limit?: number): Promise<SessionInfo[]> {
|
||||
// Read history.jsonl for fast session index
|
||||
let historyEntries: HistoryEntry[] = [];
|
||||
|
||||
try {
|
||||
const stream = createReadStream(HISTORY_FILE);
|
||||
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line) as HistoryEntry;
|
||||
if (entry.session_id && entry.ts) {
|
||||
historyEntries.push(entry);
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
}
|
||||
} catch {
|
||||
// history.jsonl doesn't exist or is unreadable — return empty
|
||||
return [];
|
||||
}
|
||||
|
||||
// Sort by timestamp descending (newest first)
|
||||
historyEntries.sort((a, b) => b.ts - a.ts);
|
||||
|
||||
// Deduplicate by session_id — history.jsonl has one entry per user message,
|
||||
// keep only the first (newest) entry per session
|
||||
const seen = new Set<string>();
|
||||
historyEntries = historyEntries.filter(e => {
|
||||
if (seen.has(e.session_id)) return false;
|
||||
seen.add(e.session_id);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Apply limit
|
||||
if (limit && limit > 0) {
|
||||
historyEntries = historyEntries.slice(0, limit);
|
||||
}
|
||||
|
||||
// For each history entry, find the JSONL file and parse session_meta
|
||||
const sessions = await Promise.all(
|
||||
historyEntries.map(async (entry): Promise<SessionInfo | null> => {
|
||||
try {
|
||||
const filePath = await findSessionFile(entry.session_id);
|
||||
|
||||
let cwd: string | null = null;
|
||||
let model: string | null = null;
|
||||
|
||||
if (filePath) {
|
||||
const meta = await parseSessionMeta(filePath);
|
||||
cwd = meta.cwd;
|
||||
model = meta.model;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: entry.session_id,
|
||||
cwd,
|
||||
lastModified: entry.ts * 1000, // Convert to ms timestamp
|
||||
firstPrompt: entry.text
|
||||
? entry.text.replace(/^(?:\[CLAWTAP_REF:[^\]]+\]|\d+\])(?:\\n|\n)?/, '').slice(0, 200)
|
||||
: null,
|
||||
model,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
let result = sessions.filter((s): s is SessionInfo => s !== null);
|
||||
|
||||
// Filter by directory if provided (same behavior as Claude's per-project filtering)
|
||||
if (dir) {
|
||||
result = result.filter(s => s.cwd === dir);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Message Reading ---
|
||||
|
||||
export async function getMessages(sessionId: string, dir?: string): Promise<MessagesResult> {
|
||||
const filePath = await findSessionFile(sessionId);
|
||||
|
||||
if (!filePath) {
|
||||
return { messages: [], lastModified: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const entries: CodexJsonlEntry[] = [];
|
||||
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: CodexJsonlEntry = JSON.parse(line);
|
||||
entries.push(entry);
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
}
|
||||
|
||||
const parser = new CodexTranscriptParser();
|
||||
const messages = parser.parseForHistory(entries);
|
||||
|
||||
const fileMtime = await stat(filePath);
|
||||
return { messages, lastModified: fileMtime.mtime.toISOString() };
|
||||
} catch {
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/** A content block within a Codex message */
|
||||
export interface CodexContentBlock {
|
||||
type: 'input_text' | 'output_text' | 'input_image' | string;
|
||||
text?: string;
|
||||
image?: { url: string };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Standard normalized content block (matches claw-tap frontend format) */
|
||||
export interface NormalizedBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const SYSTEM_PATTERNS: RegExp[] = [
|
||||
/<permissions instructions>/i,
|
||||
/<environment_context>/i,
|
||||
/AGENTS\.md/,
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert a Codex content block to the standard format used by claw-tap.
|
||||
*
|
||||
* - `input_text` → `{ type: 'text', text }`
|
||||
* - `output_text` → `{ type: 'text', text }`
|
||||
* - Unknown types are passed through as-is.
|
||||
*/
|
||||
export function normalizeContentBlock(block: CodexContentBlock): NormalizedBlock {
|
||||
if (block.type === 'input_text' || block.type === 'output_text') {
|
||||
return { type: 'text', text: block.text };
|
||||
}
|
||||
return { ...block } as NormalizedBlock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract readable text from an array of Codex content blocks.
|
||||
* Concatenates all text-bearing blocks (input_text / output_text) with newlines.
|
||||
*/
|
||||
export function extractText(content: CodexContentBlock[]): string {
|
||||
return content
|
||||
.filter((b) => b.type === 'input_text' || b.type === 'output_text')
|
||||
.map((b) => b.text ?? '')
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if this message should be filtered out as a system message.
|
||||
*
|
||||
* Matches:
|
||||
* - `role === 'developer'`
|
||||
* - Text containing `<permissions instructions>`, `<environment_context>`, or `AGENTS.md`
|
||||
*/
|
||||
export function isSystemMessage(role: string, content: CodexContentBlock[]): boolean {
|
||||
if (role === 'developer') return true;
|
||||
|
||||
const text = extractText(content);
|
||||
for (const pattern of SYSTEM_PATTERNS) {
|
||||
if (pattern.test(text)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// server/adapters/codex/pane-monitor.ts
|
||||
//
|
||||
// Polls a tmux pane every 500ms to capture real-time streaming output from
|
||||
// the Codex CLI running in --no-alt-screen mode.
|
||||
//
|
||||
// Detects:
|
||||
// 1. Streaming response text (new text since last poll)
|
||||
// 2. Thinking indicators (spinner / processing patterns)
|
||||
// 3. Approval prompts (Codex waiting for user to approve a command)
|
||||
//
|
||||
// Modelled after server/adapters/claude/pane-monitor.ts but with
|
||||
// Codex-specific regex patterns. Patterns are conservative placeholders
|
||||
// that will be refined through empirical testing with the actual Codex TUI.
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
/** Minimal interface for the tmux manager dependency */
|
||||
interface TmuxCapture {
|
||||
capturePane(windowId: string, lines?: number): Promise<string>;
|
||||
}
|
||||
|
||||
/** Thinking indicator detected from pane content */
|
||||
export interface ThinkingInfo {
|
||||
text: string;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexPaneMonitor — polls a tmux pane to detect streaming text,
|
||||
* thinking indicators, and approval prompts from the Codex CLI.
|
||||
*
|
||||
* Events emitted via the injected EventEmitter:
|
||||
* - 'streaming-text' (sessionId, newText)
|
||||
* - 'thinking' (sessionId, { text, detail })
|
||||
* - 'approval-prompt' (sessionId, { command, explanation })
|
||||
*/
|
||||
export class CodexPaneMonitor {
|
||||
private sessionId: string;
|
||||
private windowId: string;
|
||||
private tmux: TmuxCapture;
|
||||
private emitter: EventEmitter;
|
||||
private interval: ReturnType<typeof setInterval> | null = null;
|
||||
private _lastContent: string = '';
|
||||
private _lastResponseText: string = '';
|
||||
|
||||
constructor(
|
||||
sessionId: string,
|
||||
windowId: string,
|
||||
tmuxManager: TmuxCapture,
|
||||
emitter: EventEmitter,
|
||||
) {
|
||||
this.sessionId = sessionId;
|
||||
this.windowId = windowId;
|
||||
this.tmux = tmuxManager;
|
||||
this.emitter = emitter;
|
||||
}
|
||||
|
||||
/** Begin polling the tmux pane at 500ms intervals */
|
||||
start(): void {
|
||||
if (this.interval) return;
|
||||
this.interval = setInterval(() => this._poll(), 500);
|
||||
}
|
||||
|
||||
/** Stop polling and clear the interval */
|
||||
stop(): void {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Force an immediate poll (useful on hook receipt) */
|
||||
async pollNow(): Promise<void> {
|
||||
await this._poll();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async _poll(): Promise<void> {
|
||||
try {
|
||||
const content = await this.tmux.capturePane(this.windowId);
|
||||
if (content === this._lastContent) return;
|
||||
this._lastContent = content;
|
||||
|
||||
// 1. Check for approval prompt (highest priority — blocks everything)
|
||||
const approval = detectApprovalPrompt(content);
|
||||
if (approval) {
|
||||
this.emitter.emit('approval-prompt', this.sessionId, approval);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Check for thinking indicator
|
||||
const thinking = detectThinking(content);
|
||||
if (thinking) {
|
||||
this.emitter.emit('thinking', this.sessionId, thinking);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Extract streaming response text
|
||||
const text = extractResponseText(content);
|
||||
if (text && text !== this._lastResponseText) {
|
||||
this._lastResponseText = text;
|
||||
this.emitter.emit('streaming-text', this.sessionId, text);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — tmux window may have been killed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Detection functions (exported for unit testing)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Detect Codex thinking/processing indicators.
|
||||
*
|
||||
* Codex CLI shows various spinner/processing patterns while reasoning.
|
||||
* In --no-alt-screen mode these appear as inline text in the pane.
|
||||
*
|
||||
* Placeholder patterns — will be refined through empirical testing:
|
||||
* - "Thinking..." or "Reasoning..." text
|
||||
* - Spinner characters (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ braille spinner set)
|
||||
* - "Loading..." or processing indicators
|
||||
*/
|
||||
export function detectThinking(content: string): ThinkingInfo | null {
|
||||
const lines = content.split('\n');
|
||||
// Only check the tail of the pane (last 15 lines)
|
||||
const tail = lines.slice(-15);
|
||||
|
||||
for (const line of tail) {
|
||||
// Skip completion/summary lines
|
||||
if (/completed|finished|done|exited/i.test(line)) continue;
|
||||
|
||||
// Pattern 1: Braille spinner followed by descriptive text
|
||||
// e.g. "⠙ Thinking..." or "⠹ Processing..."
|
||||
const brailleMatch = line.match(/^\s*([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏])\s+(.+?)\s*$/);
|
||||
if (brailleMatch) {
|
||||
return { text: brailleMatch[2]!, detail: null };
|
||||
}
|
||||
|
||||
// Pattern 2: Explicit "Thinking..." or "Reasoning..." text
|
||||
const thinkingMatch = line.match(/^\s*(Thinking|Reasoning|Processing)(\.\.\.)?\s*(?:\((.+?)\))?\s*$/i);
|
||||
if (thinkingMatch) {
|
||||
return {
|
||||
text: `${thinkingMatch[1]}...`,
|
||||
detail: thinkingMatch[3] || null,
|
||||
};
|
||||
}
|
||||
|
||||
// Pattern 3: Dash/line spinner (e.g. "- thinking" or "| working")
|
||||
const dashSpinner = line.match(/^\s*[|/\-\\]\s+(thinking|reasoning|working)\b/i);
|
||||
if (dashSpinner) {
|
||||
return { text: `${dashSpinner[1]}...`, detail: null };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the current streaming response text from pane content.
|
||||
*
|
||||
* Codex in --no-alt-screen mode writes responses inline. We look for text
|
||||
* after the last user input marker and collect lines until we hit a
|
||||
* boundary indicator.
|
||||
*
|
||||
* Placeholder patterns — will be refined through empirical testing:
|
||||
* - User input prompt: ">" or "❯" followed by user text
|
||||
* - Response boundary: horizontal rules, new prompts, tool output markers
|
||||
*/
|
||||
export function extractResponseText(content: string): string {
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find the LAST user prompt line — responses appear after it
|
||||
let lastUserPrompt = -1;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i]!;
|
||||
// Codex user prompt patterns (conservative):
|
||||
// - ">" or "❯" at start of line followed by user text
|
||||
// - "user:" prefix
|
||||
if (/^\s*[>❯]\s+\S/.test(line) || /^\s*user:\s/i.test(line)) {
|
||||
lastUserPrompt = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUserPrompt === -1) return '';
|
||||
|
||||
// Collect response lines after the user prompt
|
||||
// Skip the prompt line itself and any blank lines immediately after
|
||||
let responseStart = lastUserPrompt + 1;
|
||||
while (responseStart < lines.length && lines[responseStart]!.trim() === '') {
|
||||
responseStart++;
|
||||
}
|
||||
|
||||
if (responseStart >= lines.length) return '';
|
||||
|
||||
const responseLines: string[] = [];
|
||||
for (let i = responseStart; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
|
||||
// Stop at boundary markers
|
||||
if (
|
||||
// Horizontal rules
|
||||
/^[─━═\-]{5,}/.test(line.trim()) ||
|
||||
// New user prompt
|
||||
/^\s*[>❯]\s+\S/.test(line) ||
|
||||
// Spinner/thinking indicators (braille set)
|
||||
/^\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+/.test(line) ||
|
||||
// Tool execution markers (Codex shows commands it wants to run)
|
||||
/^\s*\$\s+/.test(line) ||
|
||||
// Approval prompt boundary
|
||||
/approve|deny|allow|reject/i.test(line) && /\?\s*$/.test(line.trim())
|
||||
) {
|
||||
break;
|
||||
}
|
||||
responseLines.push(line);
|
||||
}
|
||||
|
||||
return responseLines.join('\n').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect an approval prompt — Codex waiting for user to approve a command.
|
||||
*
|
||||
* When Codex wants to execute a command (shell, file write, etc.) and
|
||||
* requires approval, it displays the command and waits for input.
|
||||
*
|
||||
* Placeholder patterns — will be refined through empirical testing:
|
||||
* - "Run command?" or "Execute?" prompts
|
||||
* - Command display followed by [y/n] or approve/deny prompt
|
||||
*/
|
||||
export function detectApprovalPrompt(
|
||||
content: string,
|
||||
): { command: string; explanation: string } | null {
|
||||
const lines = content.split('\n');
|
||||
// Only check the tail of the pane (last 20 lines) for approval prompts
|
||||
const tail = lines.slice(-20);
|
||||
const tailText = tail.join('\n');
|
||||
|
||||
// Pattern 1: "Run <command>? [y/n]" style
|
||||
const runMatch = tailText.match(
|
||||
/(?:Run|Execute|Allow)\s+(?:command\s*)?[:\-]?\s*[`"]?(.+?)[`"]?\s*\?\s*(?:\[([yYnN/]+)\])?\s*$/m,
|
||||
);
|
||||
if (runMatch) {
|
||||
return {
|
||||
command: runMatch[1]!.trim(),
|
||||
explanation: 'Codex is requesting approval to run a command',
|
||||
};
|
||||
}
|
||||
|
||||
// Pattern 2: Command displayed in a block followed by approval prompt
|
||||
// e.g.:
|
||||
// $ some-command --flag
|
||||
// Approve? (y/n)
|
||||
const blockMatch = tailText.match(
|
||||
/\$\s+(.+)\n[\s\S]*?(?:Approve|Allow|Confirm)\s*\?\s*(?:\(([yYnN/]+)\))?\s*$/m,
|
||||
);
|
||||
if (blockMatch) {
|
||||
return {
|
||||
command: blockMatch[1]!.trim(),
|
||||
explanation: 'Codex is requesting approval to execute a command',
|
||||
};
|
||||
}
|
||||
|
||||
// Pattern 3: Generic approval/permission prompt at the end of pane
|
||||
// Catches "Do you want to proceed?" style prompts
|
||||
const genericMatch = tail.slice(-5).join('\n').match(
|
||||
/(?:proceed|continue|approve|allow)\s*\?\s*(?:\(([yYnN/]+)\))?\s*$/im,
|
||||
);
|
||||
if (genericMatch) {
|
||||
// Try to extract the command from lines above the prompt
|
||||
const commandLine = tail.slice(-10, -3).find((l) => /^\s*\$\s+\S/.test(l));
|
||||
return {
|
||||
command: commandLine ? commandLine.replace(/^\s*\$\s+/, '').trim() : '(unknown)',
|
||||
explanation: 'Codex is requesting approval to proceed',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import {
|
||||
normalizeContentBlock,
|
||||
extractText,
|
||||
isSystemMessage,
|
||||
} from './message-utils.js';
|
||||
import type { CodexContentBlock, NormalizedBlock } from './message-utils.js';
|
||||
import type { ChatMessage, MessageContent } from '../../types/messages.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Codex JSONL entry shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CodexJsonlEntry {
|
||||
timestamp: string; // ISO 8601
|
||||
type:
|
||||
| 'session_meta'
|
||||
| 'response_item'
|
||||
| 'event_msg'
|
||||
| 'turn_context'
|
||||
| 'compacted';
|
||||
payload: any;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pending tool tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PendingTool {
|
||||
toolUseId: string;
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
status: 'running' | 'success' | 'error';
|
||||
result: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse result types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ToolStartEvent {
|
||||
toolId: string;
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolDoneEvent {
|
||||
toolId: string;
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
result: string;
|
||||
}
|
||||
|
||||
export interface StatusUpdate {
|
||||
contextPercent: number | null;
|
||||
model: string | null;
|
||||
cost: number | null;
|
||||
}
|
||||
|
||||
export interface ProcessResult {
|
||||
messages: ChatMessage[];
|
||||
toolStarts: ToolStartEvent[];
|
||||
toolDones: ToolDoneEvent[];
|
||||
statusUpdate: StatusUpdate | null;
|
||||
toolUpdates: Record<string, any> | null;
|
||||
turnComplete: boolean; // true when task_complete or turn_aborted is seen
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CodexTranscriptParser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class CodexTranscriptParser {
|
||||
pendingTools: Map<string, PendingTool>;
|
||||
private _model: string | null;
|
||||
private _msgIndex: number = 0;
|
||||
|
||||
constructor() {
|
||||
this.pendingTools = new Map();
|
||||
this._model = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process new JSONL entries into frontend-ready events.
|
||||
*
|
||||
* Returns messages, tool lifecycle events, and status updates.
|
||||
*/
|
||||
processNewEntries(entries: CodexJsonlEntry[]): ProcessResult {
|
||||
const messages: ChatMessage[] = [];
|
||||
const toolStarts: ToolStartEvent[] = [];
|
||||
const toolDones: ToolDoneEvent[] = [];
|
||||
let statusUpdate: StatusUpdate | null = null;
|
||||
let turnComplete = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
switch (entry.type) {
|
||||
case 'response_item':
|
||||
this._processResponseItem(entry.payload, messages, toolStarts, toolDones);
|
||||
break;
|
||||
|
||||
case 'event_msg': {
|
||||
const result = this._processEventMsg(entry.payload, statusUpdate);
|
||||
statusUpdate = result.status;
|
||||
if (result.turnComplete) turnComplete = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'turn_context':
|
||||
if (entry.payload?.model) {
|
||||
this._model = entry.payload.model;
|
||||
// Ensure statusUpdate reflects the newly-seen model
|
||||
if (!statusUpdate) {
|
||||
statusUpdate = { contextPercent: null, model: this._model, cost: null };
|
||||
} else {
|
||||
statusUpdate.model = this._model;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'session_meta':
|
||||
case 'compacted':
|
||||
// Skip — metadata / compaction markers
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Build toolUpdates map if any pending tools changed
|
||||
let toolUpdates: Record<string, any> | null = null;
|
||||
if (toolStarts.length > 0 || toolDones.length > 0) {
|
||||
const running = this.getPendingTools();
|
||||
if (running.size > 0) {
|
||||
toolUpdates = Object.fromEntries(running);
|
||||
}
|
||||
}
|
||||
|
||||
return { messages, toolStarts, toolDones, statusUpdate, toolUpdates, turnComplete };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse entries for history view — returns only messages (with tool blocks inlined).
|
||||
*/
|
||||
parseForHistory(entries: CodexJsonlEntry[]): ChatMessage[] {
|
||||
this._msgIndex = 0;
|
||||
const messages: ChatMessage[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== 'response_item') continue;
|
||||
|
||||
const payload = entry.payload;
|
||||
if (!payload) continue;
|
||||
|
||||
const itemType = payload.type;
|
||||
|
||||
if (itemType === 'message') {
|
||||
const role = payload.role;
|
||||
if (role === 'developer') continue;
|
||||
|
||||
const content = Array.isArray(payload.content) ? payload.content : [];
|
||||
|
||||
if (role === 'user') {
|
||||
if (isSystemMessage(role, content)) continue;
|
||||
const normalized = this._normalizeContentBlocks(content);
|
||||
if (normalized.length > 0) {
|
||||
messages.push({ id: `msg-${this._msgIndex++}`, role: 'user', content: normalized, adapter: 'codex' });
|
||||
}
|
||||
} else if (role === 'assistant') {
|
||||
const normalized = this._normalizeContentBlocks(content);
|
||||
if (normalized.length > 0) {
|
||||
messages.push({ id: `msg-${this._msgIndex++}`, role: 'assistant', content: normalized, adapter: 'codex' });
|
||||
}
|
||||
}
|
||||
} else if (itemType === 'function_call' || itemType === 'custom_tool_call') {
|
||||
// Inline as tool_use block in the last assistant message, or create one
|
||||
const toolBlock: MessageContent = {
|
||||
type: 'tool_use',
|
||||
id: payload.call_id || '',
|
||||
name: payload.name || 'unknown',
|
||||
input: this._safeParseInput(payload.arguments || payload.input),
|
||||
};
|
||||
this._appendToLastAssistant(messages, toolBlock);
|
||||
} else if (itemType === 'function_call_output' || itemType === 'custom_tool_call_output') {
|
||||
// Inline as tool_result block
|
||||
const resultBlock: MessageContent = {
|
||||
type: 'tool_result',
|
||||
tool_use_id: payload.call_id || '',
|
||||
content: typeof payload.output === 'string' ? payload.output : JSON.stringify(payload.output ?? ''),
|
||||
};
|
||||
this._appendToLastAssistant(messages, resultBlock);
|
||||
}
|
||||
// reasoning, web_search_call — skip for history
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/** Return only tools with status 'running'. */
|
||||
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 helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private _processResponseItem(
|
||||
payload: any,
|
||||
messages: ChatMessage[],
|
||||
toolStarts: ToolStartEvent[],
|
||||
toolDones: ToolDoneEvent[],
|
||||
): void {
|
||||
if (!payload) return;
|
||||
|
||||
const itemType = payload.type;
|
||||
|
||||
switch (itemType) {
|
||||
case 'message':
|
||||
this._processMessage(payload, messages);
|
||||
break;
|
||||
|
||||
case 'function_call':
|
||||
case 'custom_tool_call':
|
||||
this._processToolCall(payload, toolStarts);
|
||||
break;
|
||||
|
||||
case 'function_call_output':
|
||||
case 'custom_tool_call_output':
|
||||
this._processToolOutput(payload, toolDones);
|
||||
break;
|
||||
|
||||
case 'reasoning':
|
||||
case 'web_search_call':
|
||||
// Skip
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private _processMessage(payload: any, messages: ChatMessage[]): void {
|
||||
const role = payload.role;
|
||||
const content: CodexContentBlock[] = Array.isArray(payload.content) ? payload.content : [];
|
||||
|
||||
if (role === 'developer') return;
|
||||
|
||||
if (role === 'user') {
|
||||
if (isSystemMessage(role, content)) return;
|
||||
const normalized = this._normalizeContentBlocks(content);
|
||||
if (normalized.length > 0) {
|
||||
messages.push({ id: `msg-${this._msgIndex++}`, role: 'user', content: normalized, adapter: 'codex' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
const normalized = this._normalizeContentBlocks(content);
|
||||
if (normalized.length > 0) {
|
||||
messages.push({ id: `msg-${this._msgIndex++}`, role: 'assistant', content: normalized, adapter: 'codex' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _processToolCall(payload: any, toolStarts: ToolStartEvent[]): void {
|
||||
const callId = payload.call_id || '';
|
||||
const name = payload.name || 'unknown';
|
||||
const input = this._safeParseInput(payload.arguments || payload.input);
|
||||
|
||||
// Track as pending
|
||||
this.pendingTools.set(callId, {
|
||||
toolUseId: callId,
|
||||
name,
|
||||
input,
|
||||
status: 'running',
|
||||
result: null,
|
||||
});
|
||||
|
||||
toolStarts.push({ toolId: callId, toolName: name, input });
|
||||
}
|
||||
|
||||
private _processToolOutput(payload: any, toolDones: ToolDoneEvent[]): void {
|
||||
const callId = payload.call_id || '';
|
||||
const output = typeof payload.output === 'string'
|
||||
? payload.output
|
||||
: JSON.stringify(payload.output ?? '');
|
||||
|
||||
const pending = this.pendingTools.get(callId);
|
||||
if (pending) {
|
||||
pending.status = 'success';
|
||||
pending.result = output;
|
||||
this.pendingTools.delete(callId);
|
||||
}
|
||||
|
||||
toolDones.push({
|
||||
toolId: callId,
|
||||
toolName: pending?.name || 'unknown',
|
||||
input: pending?.input || {},
|
||||
result: output,
|
||||
});
|
||||
}
|
||||
|
||||
private _processEventMsg(
|
||||
payload: any,
|
||||
current: StatusUpdate | null,
|
||||
): { status: StatusUpdate | null; turnComplete: boolean } {
|
||||
if (!payload) return { status: current, turnComplete: false };
|
||||
|
||||
if (payload.type === 'token_count') {
|
||||
const contextPercent = payload.rate_limits?.primary?.used_percent ?? null;
|
||||
const status: StatusUpdate = {
|
||||
contextPercent: contextPercent != null ? Math.round(contextPercent) : null,
|
||||
model: this._model,
|
||||
cost: null,
|
||||
};
|
||||
return { status, turnComplete: false };
|
||||
}
|
||||
|
||||
if (payload.type === 'task_complete' || payload.type === 'turn_aborted') {
|
||||
return { status: current, turnComplete: true };
|
||||
}
|
||||
|
||||
// agent_message, task_started — skip
|
||||
return { status: current, turnComplete: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Codex content blocks to the standard MessageContent format.
|
||||
*/
|
||||
private _normalizeContentBlocks(blocks: CodexContentBlock[]): MessageContent[] {
|
||||
const result: MessageContent[] = [];
|
||||
for (const block of blocks) {
|
||||
const normalized = normalizeContentBlock(block);
|
||||
// Only include blocks that carry meaningful content
|
||||
if (normalized.type === 'text' && normalized.text != null) {
|
||||
result.push({ type: 'text', text: normalized.text });
|
||||
} else if (normalized.type === 'tool_use') {
|
||||
result.push({
|
||||
type: 'tool_use',
|
||||
id: (normalized as any).id || '',
|
||||
name: (normalized as any).name || 'unknown',
|
||||
input: (normalized as any).input || {},
|
||||
});
|
||||
} else if (normalized.type === 'tool_result') {
|
||||
result.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: (normalized as any).tool_use_id || '',
|
||||
content: typeof (normalized as any).content === 'string'
|
||||
? (normalized as any).content
|
||||
: JSON.stringify((normalized as any).content ?? ''),
|
||||
});
|
||||
}
|
||||
// Other block types (input_image, etc.) can be added later
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse a tool input that may be a JSON string or already an object.
|
||||
*/
|
||||
private _safeParseInput(input: unknown): Record<string, unknown> {
|
||||
if (input == null) return {};
|
||||
if (typeof input === 'object' && !Array.isArray(input)) {
|
||||
return input as Record<string, unknown>;
|
||||
}
|
||||
if (typeof input === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(input);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return { _raw: input };
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a content block to the last assistant message, creating one if needed.
|
||||
* Used by parseForHistory to inline tool_use / tool_result blocks.
|
||||
*/
|
||||
private _appendToLastAssistant(messages: ChatMessage[], block: MessageContent): void {
|
||||
const last = messages.length > 0 ? messages[messages.length - 1] : null;
|
||||
if (last && last.role === 'assistant') {
|
||||
last.content.push(block);
|
||||
} else {
|
||||
// Create a new assistant message to hold this block
|
||||
messages.push({ id: `msg-${this._msgIndex++}`, role: 'assistant', content: [block], adapter: 'codex' });
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Reads JSON from stdin (Gemini hook protocol), POSTs to claw-tap server.
|
||||
# IMPORTANT: Gemini hooks expect a JSON response on stdout.
|
||||
# Must write response BEFORE backgrounding curl, or Gemini hangs.
|
||||
#
|
||||
# Usage: bridge.sh <endpoint> <port> <protocol>
|
||||
# e.g.: bridge.sh session-start 3456 https
|
||||
ENDPOINT="$1"
|
||||
PORT="${2:-3456}"
|
||||
PROTOCOL="${3:-http}"
|
||||
CURL_K=""
|
||||
[ "$PROTOCOL" = "https" ] && CURL_K="-k"
|
||||
|
||||
input=$(cat)
|
||||
printf '{}'
|
||||
|
||||
printf '%s' "$input" | curl -sf $CURL_K --connect-timeout 2 --max-time 5 \
|
||||
-X POST -H 'Content-Type:application/json' -d @- \
|
||||
"${PROTOCOL}://localhost:${PORT}/api/hooks/gemini/${ENDPOINT}" &>/dev/null &
|
||||
@@ -0,0 +1,848 @@
|
||||
// server/adapters/gemini/gemini-tmux-adapter.ts
|
||||
//
|
||||
// Session lifecycle management for Gemini CLI sessions running in tmux.
|
||||
//
|
||||
// Key difference from Codex's CodexTmuxAdapter:
|
||||
// - Gemini has 6 hooks: SessionStart, SessionEnd, BeforeTool, AfterTool, BeforeAgent, AfterAgent
|
||||
// - Tool lifecycle comes from hooks (BeforeTool/AfterTool), not just JSON watching
|
||||
// - Uses JsonWatcher (full JSON reparse) instead of JsonlWatcher (append-only)
|
||||
// - Permission mode uses Ctrl+Y for default <-> yolo toggle
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { tmuxManager } from '../shared/tmux-manager.js';
|
||||
import { GeminiPaneMonitor } from './pane-monitor.js';
|
||||
import { JsonWatcher } from '../../stores/json-watcher.js';
|
||||
import { GeminiTranscriptParser } from './transcript-parser.js';
|
||||
import type { GeminiSessionMessage } from '../../stores/json-watcher.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 { readFile } from 'fs/promises';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Hook body payload from the Gemini CLI */
|
||||
export interface GeminiHookBody {
|
||||
session_id?: string;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
hook_event_name?: string;
|
||||
transcript_path?: string;
|
||||
tool_name?: string;
|
||||
tool_input?: Record<string, unknown>;
|
||||
tool_response?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Internal session state for a managed tmux session */
|
||||
export interface GeminiSessionState {
|
||||
windowId: string;
|
||||
monitor: GeminiPaneMonitor | null;
|
||||
watcher: JsonWatcher | null;
|
||||
parser: GeminiTranscriptParser | null;
|
||||
cwd: string;
|
||||
cliSessionId: string;
|
||||
transcriptPath: string | null;
|
||||
permissionMode: string;
|
||||
lastActivity: number;
|
||||
firstPrompt: string | null;
|
||||
isProcessing: boolean;
|
||||
_promptSenderClientId: string | null;
|
||||
_watcherPending: boolean;
|
||||
_matchRetryTimer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
/** Hook body with timestamp for age-based cleanup */
|
||||
type PendingHookBody = GeminiHookBody & { _storedAt: number };
|
||||
|
||||
/** Resolved session context from _resolveAndTouch */
|
||||
interface ResolvedContext {
|
||||
sessionId: string;
|
||||
session: GeminiSessionState | undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GeminiTmuxAdapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GeminiTmuxAdapter — manages Gemini CLI sessions via tmux.
|
||||
*
|
||||
* Three channels provide events to the SessionManager:
|
||||
* 1. HTTP Hooks (lifecycle): SessionStart, SessionEnd, BeforeTool, AfterTool, BeforeAgent, AfterAgent
|
||||
* 2. JSON Watcher (messages): new-messages, thinking, status-update
|
||||
* 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)
|
||||
* processing-started(sessionId)
|
||||
* status-update(sessionId, { model, tokens })
|
||||
* session-error(sessionId, { errorType, errorDetails })
|
||||
* session-ended(sessionId)
|
||||
* session-rekeyed(oldKey, newKey)
|
||||
*/
|
||||
export class GeminiTmuxAdapter extends EventEmitter {
|
||||
// sessionId -> session state
|
||||
sessions: Map<string, GeminiSessionState>;
|
||||
private _permissions: PermissionManager;
|
||||
private _clientChecker: ((sessionId: string) => boolean) | null;
|
||||
private _cleanupInterval: ReturnType<typeof setInterval> | null;
|
||||
private _pendingHookBodies: Map<string, PendingHookBody> = new Map();
|
||||
// Track tool IDs from BeforeTool → AfterTool so events correlate
|
||||
private _activeToolId: string | null = null;
|
||||
|
||||
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 }> {
|
||||
const mode = options.permissionMode || 'default';
|
||||
const parts = ['gemini', '--approval-mode', this._toCliApprovalMode(mode)];
|
||||
if (options.model) parts.push('-m', options.model);
|
||||
|
||||
const tempName = `gemini-${Date.now()}`;
|
||||
const windowId = await tmuxManager.createWindow(tempName, cwd, parts.join(' '));
|
||||
|
||||
// Register session BEFORE _waitForReady — SessionStart hook fires during
|
||||
// CLI startup and needs to find this session in the Map for matching.
|
||||
this.sessions.set(tempName, this._createSession(windowId, cwd, '', mode));
|
||||
|
||||
await this._waitForReady(windowId);
|
||||
|
||||
// After _waitForReady, SessionStart hook may have fired and rekeyed
|
||||
// the session from tempName to the real CLI UUID. Return the current key.
|
||||
let finalId = tempName;
|
||||
if (!this.sessions.has(tempName)) {
|
||||
const rekeyed = [...this.sessions.entries()].find(([, s]) => s.windowId === windowId)?.[0];
|
||||
if (rekeyed) {
|
||||
finalId = rekeyed;
|
||||
} else {
|
||||
console.warn(`[gemini-tmux] Session ${tempName} vanished during startup (windowId=${windowId})`);
|
||||
}
|
||||
}
|
||||
|
||||
this._startMonitor(finalId, windowId);
|
||||
|
||||
return { sessionId: finalId };
|
||||
}
|
||||
|
||||
async resumeSession(sessionId: string, cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
const geminiUuid = session?.cliSessionId || sessionId;
|
||||
const mode = options.permissionMode || session?.permissionMode || 'default';
|
||||
|
||||
// Check if tmux window still alive
|
||||
if (session) {
|
||||
const windows = await tmuxManager.listWindows();
|
||||
if (windows.some(w => w.id === session.windowId)) {
|
||||
if (!session.monitor) this._startMonitor(sessionId, session.windowId);
|
||||
session.permissionMode = mode;
|
||||
session.lastActivity = Date.now();
|
||||
return { sessionId };
|
||||
}
|
||||
// Window gone — teardown old
|
||||
this._teardownSession(session);
|
||||
}
|
||||
|
||||
const parts = ['gemini', '--resume', geminiUuid, '--approval-mode', this._toCliApprovalMode(mode)];
|
||||
if (options.model) parts.push('-m', options.model);
|
||||
|
||||
const newSessionId = geminiUuid;
|
||||
const windowId = await tmuxManager.createWindow(geminiUuid, cwd, parts.join(' '));
|
||||
|
||||
// Register before _waitForReady — same pattern as startSession
|
||||
if (session) {
|
||||
if (sessionId !== newSessionId) this.sessions.delete(sessionId);
|
||||
session.windowId = windowId;
|
||||
session.lastActivity = Date.now();
|
||||
session.permissionMode = mode;
|
||||
session._watcherPending = true;
|
||||
session.transcriptPath = null;
|
||||
session.watcher = null;
|
||||
session.parser = null;
|
||||
this.sessions.set(newSessionId, session);
|
||||
} else {
|
||||
this.sessions.set(newSessionId, this._createSession(windowId, cwd, geminiUuid, mode));
|
||||
}
|
||||
|
||||
await this._waitForReady(windowId);
|
||||
|
||||
this._startMonitor(newSessionId, windowId);
|
||||
return { sessionId: newSessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle permission mode via Ctrl+Y.
|
||||
* Only supports binary toggle: default <-> yolo at runtime.
|
||||
* auto_edit and plan are only settable at session launch.
|
||||
*/
|
||||
async switchPermissionMode(sessionId: string, targetMode: string): Promise<boolean> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return false;
|
||||
// Ctrl+Y toggles default <-> yolo
|
||||
await tmuxManager.sendControl(session.windowId, 'C-y');
|
||||
session.permissionMode = targetMode;
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
session.isProcessing = true;
|
||||
|
||||
// Restart pane monitor if it was stopped
|
||||
if (!session.monitor) {
|
||||
this._startMonitor(sessionId, session.windowId);
|
||||
}
|
||||
|
||||
if (isLargeContent(text)) {
|
||||
const singleLine = text.replace(/\n/g, '\\n');
|
||||
|
||||
const markerMatch = singleLine.match(/^\[CLAWTAP_REF:[^\]]+\]/);
|
||||
if (markerMatch) {
|
||||
const marker = markerMatch[0];
|
||||
const rest = singleLine.substring(marker.length);
|
||||
await tmuxManager.sendKeys(session.windowId, marker, false);
|
||||
await new Promise<void>(r => setTimeout(r, 200));
|
||||
if (rest) {
|
||||
await tmuxManager.pasteBuffer(session.windowId, rest, false);
|
||||
}
|
||||
} else {
|
||||
await tmuxManager.pasteBuffer(session.windowId, singleLine, false);
|
||||
}
|
||||
await new Promise<void>(r => setTimeout(r, 300));
|
||||
await tmuxManager.sendControl(session.windowId, 'Enter');
|
||||
} else {
|
||||
await tmuxManager.sendKeys(session.windowId, text, false);
|
||||
await new Promise<void>(r => setTimeout(r, 200));
|
||||
await tmuxManager.sendControl(session.windowId, 'Enter');
|
||||
}
|
||||
|
||||
// If there are pending hook bodies waiting for marker matching, try now
|
||||
if (this._pendingHookBodies.size > 0 && session._watcherPending) {
|
||||
this._tryMatchPending(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
async switchModel(sessionId: string, model: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
await tmuxManager.sendKeys(session.windowId, `/model ${model}`, false);
|
||||
await new Promise<void>(r => setTimeout(r, 200));
|
||||
await tmuxManager.sendControl(session.windowId, 'Enter');
|
||||
}
|
||||
|
||||
async interrupt(sessionId: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
await tmuxManager.sendControl(session.windowId, 'C-c');
|
||||
session.isProcessing = false;
|
||||
if (session.monitor) {
|
||||
session.monitor.stop();
|
||||
session.monitor = null;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// === Hook Handlers ===
|
||||
|
||||
/**
|
||||
* Handle the SessionStart hook from Gemini CLI.
|
||||
*
|
||||
* This is the moment we learn the transcript_path and can start the JSON watcher.
|
||||
* It may also be the first time we see the Gemini session UUID for sessions started via startSession().
|
||||
*/
|
||||
handleSessionStart(body: GeminiHookBody): void {
|
||||
const geminiUuid = body.session_id;
|
||||
if (!geminiUuid) return;
|
||||
|
||||
// 1. Already managed (resume, or session with known UUID)
|
||||
if (this.sessions.has(geminiUuid)) {
|
||||
this._applySessionStartBody(geminiUuid, body);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Find pending sessions (_watcherPending === true)
|
||||
const pending = [...this.sessions.entries()].filter(([, s]) => s._watcherPending);
|
||||
if (pending.length === 0) return; // Not our session
|
||||
|
||||
// 3. Exactly 1 pending -> direct match (no marker needed)
|
||||
if (pending.length === 1) {
|
||||
const [tempKey] = pending[0];
|
||||
console.log(`[gemini-tmux] Direct match: ${tempKey} -> ${geminiUuid}`);
|
||||
this._rekeyAndRename(tempKey, geminiUuid);
|
||||
this._applySessionStartBody(geminiUuid, body);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Multiple pending -> store, wait for sendMessage to disambiguate via marker
|
||||
this._pendingHookBodies.set(geminiUuid, { ...body, _storedAt: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the BeforeTool hook from Gemini CLI.
|
||||
* Emits tool-start for the tool about to run.
|
||||
*/
|
||||
handleBeforeTool(body: GeminiHookBody): void {
|
||||
const ctx = this._resolveAndTouch(body);
|
||||
if (!ctx) return;
|
||||
|
||||
const { sessionId } = ctx;
|
||||
const toolId = body.tool_use_id || `${body.tool_name}-${Date.now()}`;
|
||||
this._activeToolId = toolId;
|
||||
this.emit('tool-start', sessionId, {
|
||||
toolId,
|
||||
toolName: body.tool_name || 'unknown',
|
||||
input: body.tool_input || {},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the AfterTool hook from Gemini CLI.
|
||||
* Emits tool-done for the tool that just finished.
|
||||
*/
|
||||
handleAfterTool(body: GeminiHookBody): void {
|
||||
const ctx = this._resolveAndTouch(body);
|
||||
if (!ctx) return;
|
||||
|
||||
const { sessionId } = ctx;
|
||||
// Use the toolId from BeforeTool if available, ensuring start/done events correlate
|
||||
const toolId = this._activeToolId || body.tool_use_id || `${body.tool_name}-${Date.now()}`;
|
||||
this._activeToolId = null;
|
||||
|
||||
let resultStr = '';
|
||||
if (body.tool_response !== undefined && body.tool_response !== null) {
|
||||
resultStr = typeof body.tool_response === 'string'
|
||||
? body.tool_response
|
||||
: JSON.stringify(body.tool_response);
|
||||
}
|
||||
|
||||
this.emit('tool-done', sessionId, {
|
||||
toolId,
|
||||
toolName: body.tool_name || 'unknown',
|
||||
result: resultStr,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the BeforeAgent hook from Gemini CLI.
|
||||
* Signals that the agent is starting to process.
|
||||
*/
|
||||
handleBeforeAgent(body: GeminiHookBody): void {
|
||||
const ctx = this._resolveAndTouch(body);
|
||||
if (!ctx) return;
|
||||
|
||||
const { sessionId, session } = ctx;
|
||||
if (session) {
|
||||
session.isProcessing = true;
|
||||
if (!session.monitor && session.windowId) {
|
||||
this._startMonitor(sessionId, session.windowId);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('processing-started', sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the AfterAgent hook from Gemini CLI.
|
||||
* Signals that the agent has finished processing (turn complete).
|
||||
*/
|
||||
handleAfterAgent(body: GeminiHookBody): void {
|
||||
const ctx = this._resolveAndTouch(body);
|
||||
if (!ctx) return;
|
||||
|
||||
const { sessionId, session } = ctx;
|
||||
if (session) {
|
||||
session.isProcessing = false;
|
||||
if (session.monitor) {
|
||||
session.monitor.stop();
|
||||
session.monitor = null;
|
||||
}
|
||||
// Flush JSON watcher to get final entries
|
||||
if (session.watcher) {
|
||||
session.watcher.pollNow();
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('session-idle', sessionId);
|
||||
this._permissions.dismissAll(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the SessionEnd hook from Gemini CLI.
|
||||
* Cleans up the session.
|
||||
*/
|
||||
handleSessionEnd(body: GeminiHookBody): void {
|
||||
const sessionId = body.session_id;
|
||||
if (!sessionId) return;
|
||||
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
this._teardownSession(session);
|
||||
this.sessions.delete(sessionId);
|
||||
this.emit('session-ended', sessionId);
|
||||
}
|
||||
|
||||
// === JSON Watcher ===
|
||||
|
||||
/**
|
||||
* Process new JSON messages through the transcript parser and emit events.
|
||||
*/
|
||||
private _processWatcherMessages(sessionId: string, rawMessages: GeminiSessionMessage[]): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session?.parser) return;
|
||||
|
||||
const result = session.parser.parse(rawMessages);
|
||||
|
||||
// Emit errors as session-error events
|
||||
for (const errText of result.errors) {
|
||||
this.emit('session-error', sessionId, {
|
||||
errorType: 'gemini_error',
|
||||
errorDetails: errText,
|
||||
});
|
||||
}
|
||||
|
||||
// Single pass: extract thoughts + status from gemini/info messages
|
||||
for (const msg of rawMessages) {
|
||||
if (msg.type === 'gemini') {
|
||||
const thoughts = GeminiTranscriptParser.extractThoughts(msg);
|
||||
for (const thought of thoughts) {
|
||||
this.emit('thinking', sessionId, {
|
||||
text: thought.subject || 'Thinking...',
|
||||
detail: thought.description || null,
|
||||
});
|
||||
}
|
||||
const status = GeminiTranscriptParser.extractStatus(msg);
|
||||
if (status) this.emit('status-update', sessionId, status);
|
||||
} else if (msg.type === 'info') {
|
||||
const status = GeminiTranscriptParser.extractStatus(msg);
|
||||
if (status) this.emit('status-update', sessionId, status);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit messages
|
||||
if (result.messages.length > 0) {
|
||||
// Capture first user prompt for active sessions list
|
||||
if (!session.firstPrompt) {
|
||||
const userMsg = result.messages.find(m => m.role === 'user');
|
||||
if (userMsg) {
|
||||
const text = userMsg.content
|
||||
.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
|
||||
.map(c => c.text)
|
||||
.join('\n');
|
||||
if (text) {
|
||||
const stripped = text.replace(/^(?:\[CLAWTAP_REF:[^\]]+\]|\d+\])(?:\\n|\n)?/, '');
|
||||
session.firstPrompt = stripped.substring(0, 200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tag user messages with sender's client ID so only the sender skips (dedup)
|
||||
for (const msg of result.messages) {
|
||||
if (msg.role === 'user' && session._promptSenderClientId) {
|
||||
msg.senderClientId = session._promptSenderClientId;
|
||||
session._promptSenderClientId = null;
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('new-messages', sessionId, result.messages);
|
||||
}
|
||||
}
|
||||
|
||||
// === Query Methods ===
|
||||
|
||||
getSession(sessionId: string): GeminiSessionState | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
getActiveSessions(): ActiveSessionInfo[] {
|
||||
const result: ActiveSessionInfo[] = [];
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
result.push({
|
||||
sessionId,
|
||||
cwd: session.cwd,
|
||||
adapter: 'gemini',
|
||||
permissionMode: session.permissionMode,
|
||||
lastActivity: session.lastActivity || null,
|
||||
hasClients: this._clientChecker ? this._clientChecker(sessionId) : false,
|
||||
hasDesktop: !!(session.lastActivity && (Date.now() - session.lastActivity < 120_000)),
|
||||
isNonInteractive: false,
|
||||
firstPrompt: session.firstPrompt || null,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async hasActiveWindow(sessionId: string): Promise<boolean> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return false;
|
||||
const windows = await tmuxManager.listWindows();
|
||||
return windows.some(w => w.id === session.windowId);
|
||||
}
|
||||
|
||||
isProcessing(sessionId: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
return !!(session?.isProcessing);
|
||||
}
|
||||
|
||||
/** Force an immediate JSON 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 messages */
|
||||
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 state: ReconnectState = { tools: {} as Record<string, import('../../types/messages.js').ToolStatus>, pendingRequests: [] };
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// === Permission Methods ===
|
||||
|
||||
respondPermission(requestId: string, behavior: PermissionBehavior): void {
|
||||
const pending = this._permissions.resolvePermission(requestId);
|
||||
if (!pending) return;
|
||||
|
||||
const session = this.sessions.get(pending.sessionId);
|
||||
if (!session) return;
|
||||
|
||||
if (behavior === 'allow' || behavior === 'allow_session') {
|
||||
tmuxManager.sendKeys(session.windowId, 'y', true).catch(() => {});
|
||||
} else {
|
||||
tmuxManager.sendKeys(session.windowId, 'n', true).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async respondQuestion(requestId: string, answer: string): Promise<void> {
|
||||
const pending = this._permissions.resolveQuestion(requestId);
|
||||
if (!pending) return;
|
||||
|
||||
const session = this.sessions.get(pending.sessionId);
|
||||
if (!session) return;
|
||||
|
||||
await tmuxManager.sendKeys(session.windowId, answer, true);
|
||||
}
|
||||
|
||||
/** Release all pending requests for a session */
|
||||
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) {
|
||||
tmuxManager.sendKeys(session.windowId, 'y', true).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Cleanup ===
|
||||
|
||||
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 Helpers ===
|
||||
|
||||
/** Map permission mode string to Gemini CLI --approval-mode value */
|
||||
private _toCliApprovalMode(mode: string): string {
|
||||
switch (mode) {
|
||||
case 'yolo': return 'yolo';
|
||||
case 'auto_edit': return 'auto_edit';
|
||||
case 'plan': return 'plan';
|
||||
default: return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve hook body to internal session, touch lastActivity */
|
||||
private _resolveAndTouch(body: GeminiHookBody): ResolvedContext | null {
|
||||
const sessionId = body.session_id;
|
||||
if (!sessionId) return null;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
session.lastActivity = Date.now();
|
||||
return { sessionId, session };
|
||||
}
|
||||
|
||||
private _createSession(
|
||||
windowId: string,
|
||||
cwd: string,
|
||||
cliSessionId: string,
|
||||
permissionMode: string,
|
||||
): GeminiSessionState {
|
||||
return {
|
||||
windowId,
|
||||
monitor: null,
|
||||
watcher: null,
|
||||
parser: null,
|
||||
cwd,
|
||||
cliSessionId,
|
||||
transcriptPath: null,
|
||||
permissionMode,
|
||||
lastActivity: Date.now(),
|
||||
firstPrompt: null,
|
||||
isProcessing: false,
|
||||
_promptSenderClientId: null,
|
||||
_watcherPending: true,
|
||||
_matchRetryTimer: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Gemini CLI to be ready.
|
||||
* Polls tmux pane content until a prompt indicator appears.
|
||||
*/
|
||||
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');
|
||||
// Gemini shows > or similar prompt indicator
|
||||
const hasPrompt = lines.some(l => /^\s*[>❯]/.test(l));
|
||||
const lineCount = lines.filter(l => l.trim()).length;
|
||||
if (attempt <= 3 || attempt % 5 === 0) {
|
||||
console.log(`[gemini-tmux] waitForReady #${attempt}: window=${windowId} prompt=${hasPrompt} lines=${lineCount}`);
|
||||
}
|
||||
if (hasPrompt && lineCount >= 2) {
|
||||
console.log(`[gemini-tmux] CLI ready for ${windowId} in ${Date.now() - start}ms`);
|
||||
await new Promise<void>(r => setTimeout(r, 300));
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`[gemini-tmux] waitForReady #${attempt}: ERROR ${(err as Error).message}`);
|
||||
}
|
||||
await new Promise<void>(r => setTimeout(r, 500));
|
||||
}
|
||||
console.warn(`[gemini-tmux] Timed out waiting for CLI ready on ${windowId}`);
|
||||
}
|
||||
|
||||
/** Apply hook body state and start watcher — shared by all handleSessionStart branches */
|
||||
private _applySessionStartBody(sessionId: string, body: GeminiHookBody): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
if (!session.cliSessionId) session.cliSessionId = body.session_id || '';
|
||||
if (body.cwd) session.cwd = body.cwd;
|
||||
if (body.model) {
|
||||
// Emit initial model as status update
|
||||
this.emit('status-update', sessionId, { model: body.model, tokens: null });
|
||||
}
|
||||
session.lastActivity = Date.now();
|
||||
if (body.transcript_path && !session.transcriptPath) {
|
||||
session.transcriptPath = body.transcript_path;
|
||||
}
|
||||
|
||||
// Start JSON watcher if we have a transcript path and watcher isn't already running
|
||||
if (session.transcriptPath && !session.watcher) {
|
||||
const skipExisting = session.isProcessing !== false;
|
||||
this._startWatcher(sessionId, session, skipExisting);
|
||||
}
|
||||
session._watcherPending = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after sendMessage when _pendingHookBodies has entries.
|
||||
* Reads each pending hook body's transcript_path to find the CLAWTAP_REF marker.
|
||||
*/
|
||||
private async _tryMatchPending(tempKey: string): Promise<void> {
|
||||
if (await this._scanPendingForMarker(tempKey)) return;
|
||||
|
||||
// Marker not found yet — Gemini may still be writing. Retry once after 2s.
|
||||
const session = this.sessions.get(tempKey);
|
||||
if (!session) return;
|
||||
if (session._matchRetryTimer) clearTimeout(session._matchRetryTimer);
|
||||
session._matchRetryTimer = setTimeout(async () => {
|
||||
const s = this.sessions.get(tempKey);
|
||||
if (!s || !s._watcherPending || !this._pendingHookBodies.size) return;
|
||||
await this._scanPendingForMarker(tempKey);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/** Scan _pendingHookBodies for a transcript containing CLAWTAP_REF:{tempKey}. */
|
||||
private async _scanPendingForMarker(tempKey: string): Promise<boolean> {
|
||||
for (const [uuid, body] of this._pendingHookBodies) {
|
||||
if (!body.transcript_path) continue;
|
||||
try {
|
||||
const content = await readFile(body.transcript_path, 'utf8');
|
||||
if (!content.includes(`CLAWTAP_REF:${tempKey}`)) continue;
|
||||
console.log(`[gemini-tmux] Marker match: ${tempKey} -> ${uuid}`);
|
||||
this._pendingHookBodies.delete(uuid);
|
||||
this._rekeyAndRename(tempKey, uuid);
|
||||
this._applySessionStartBody(uuid, body);
|
||||
return true;
|
||||
} catch { continue; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-key a session from tempKey to the real CLI UUID and rename the tmux window.
|
||||
*/
|
||||
private _rekeyAndRename(tempKey: string, cliUuid: string): void {
|
||||
const session = this.sessions.get(tempKey);
|
||||
if (!session) return;
|
||||
session.cliSessionId = cliUuid;
|
||||
session._watcherPending = false;
|
||||
this.sessions.delete(tempKey);
|
||||
this.sessions.set(cliUuid, session);
|
||||
tmuxManager.renameWindow(session.windowId, cliUuid).catch(() => {});
|
||||
if (session.monitor) {
|
||||
(session.monitor as any).sessionId = cliUuid;
|
||||
}
|
||||
// Notify session-manager to re-register clients under the new key
|
||||
this.emit('session-rekeyed', tempKey, cliUuid);
|
||||
}
|
||||
|
||||
private _startMonitor(sessionId: string, windowId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
if (session.monitor) {
|
||||
session.monitor.stop();
|
||||
}
|
||||
|
||||
const monitor = new GeminiPaneMonitor(sessionId, windowId, tmuxManager, this);
|
||||
monitor.start();
|
||||
session.monitor = monitor;
|
||||
}
|
||||
|
||||
private _startWatcher(sessionId: string, session: GeminiSessionState, skipExisting = true): void {
|
||||
if (!session.transcriptPath) return;
|
||||
if (session.watcher) return;
|
||||
|
||||
const parser = new GeminiTranscriptParser();
|
||||
const watcher = new JsonWatcher(session.transcriptPath);
|
||||
|
||||
watcher.onNewMessages((messages) => {
|
||||
this._processWatcherMessages(sessionId, messages);
|
||||
});
|
||||
|
||||
watcher.start({ skipExisting, fallbackIntervalMs: 1000 });
|
||||
session.watcher = watcher;
|
||||
session.parser = parser;
|
||||
session._watcherPending = false;
|
||||
}
|
||||
|
||||
private _teardownSession(session: GeminiSessionState): void {
|
||||
if (session.monitor) {
|
||||
session.monitor.stop();
|
||||
session.monitor = null;
|
||||
}
|
||||
if (session.watcher) {
|
||||
session.watcher.stop();
|
||||
session.watcher = null;
|
||||
session.parser = null;
|
||||
}
|
||||
if (session._matchRetryTimer) {
|
||||
clearTimeout(session._matchRetryTimer);
|
||||
session._matchRetryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
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 (session.windowId && !liveWindowIds.has(session.windowId)) {
|
||||
console.log(`[gemini-tmux] Stale session ${sessionId} — tmux window gone, cleaning up`);
|
||||
this._teardownSession(session);
|
||||
this.sessions.delete(sessionId);
|
||||
this.emit('session-ended', sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Cap at 10 managed sessions
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale pending hook bodies (age-based sweep)
|
||||
for (const [uuid, body] of this._pendingHookBodies) {
|
||||
const age = Date.now() - body._storedAt;
|
||||
if (age > 60_000) this._pendingHookBodies.delete(uuid);
|
||||
}
|
||||
}, 60_000);
|
||||
this._cleanupInterval.unref();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// server/adapters/gemini/hook-config.ts
|
||||
//
|
||||
// Pure filesystem operations for Gemini hook management.
|
||||
// Zero runtime dependencies — no EventEmitter, no tmux, no sessions.
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join, dirname, resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** 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[];
|
||||
}
|
||||
|
||||
/** The structure of Gemini's settings.json (partial) */
|
||||
interface GeminiSettings {
|
||||
hooks?: Record<string, HookEntry[]>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class GeminiHookConfig {
|
||||
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 ~/.gemini/settings.json */
|
||||
install(): void {
|
||||
const port = this.port;
|
||||
const settingsDir = join(homedir(), '.gemini');
|
||||
const settingsPath = join(settingsDir, 'settings.json');
|
||||
|
||||
const protocol = this.useHttps ? 'https' : 'http';
|
||||
const desiredHooks = this._buildDesiredHooks(protocol);
|
||||
|
||||
try {
|
||||
mkdirSync(settingsDir, { recursive: true });
|
||||
let existing: GeminiSettings = {};
|
||||
try { existing = JSON.parse(readFileSync(settingsPath, 'utf-8')) as GeminiSettings; } catch {}
|
||||
|
||||
// Replace our hooks on every startup.
|
||||
// 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));
|
||||
existing.hooks[event] = [...filtered, ...configs];
|
||||
}
|
||||
|
||||
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
||||
console.log(`[hooks:gemini] Auto-configured hooks in ${settingsPath}`);
|
||||
} catch (err) {
|
||||
console.warn(`[hooks:gemini] Failed to auto-configure hooks: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove ClawTap hooks from ~/.gemini/settings.json.
|
||||
* Leaves other user settings intact. Only removes hooks owned by this port.
|
||||
*/
|
||||
uninstall(): void {
|
||||
const settingsPath = join(homedir(), '.gemini', 'settings.json');
|
||||
|
||||
try {
|
||||
const existing: GeminiSettings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as GeminiSettings;
|
||||
|
||||
if (existing.hooks) {
|
||||
const hookKeys = Object.keys(this._buildDesiredHooks('http'));
|
||||
for (const key of hookKeys) {
|
||||
const entries = existing.hooks[key];
|
||||
if (!Array.isArray(entries)) continue;
|
||||
|
||||
const filtered = entries.filter(entry => !this._isOurHookEntry(entry));
|
||||
|
||||
if (filtered.length === 0) {
|
||||
delete existing.hooks[key];
|
||||
} else {
|
||||
existing.hooks[key] = filtered;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(existing.hooks).length === 0) delete existing.hooks;
|
||||
}
|
||||
|
||||
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
||||
console.log(`[hooks:gemini] Removed ClawTap hooks from ${settingsPath}`);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
|
||||
console.warn(`[hooks:gemini] Failed to remove hooks: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
private _isOurHookEntry(entry: HookEntry): boolean {
|
||||
const hooks = entry.hooks || [];
|
||||
return hooks.some(h =>
|
||||
h.command != null && h.command.includes('bridge.sh') && h.command.includes(String(this.port))
|
||||
);
|
||||
}
|
||||
|
||||
private _buildDesiredHooks(protocol: string): Record<string, HookEntry[]> {
|
||||
const port = this.port;
|
||||
const bridgePath = resolve(__dirname, 'bridge.sh');
|
||||
// Pass port and protocol as positional args (not env vars).
|
||||
// Gemini CLI may use execFile instead of shell, so inline VAR=val doesn't work.
|
||||
const mkCmd = (endpoint: string): string =>
|
||||
`${bridgePath} ${endpoint} ${port} ${protocol}`;
|
||||
|
||||
// IMPORTANT: Gemini CLI timeout is in MILLISECONDS (not seconds like Claude Code).
|
||||
// 5000ms = 5 seconds — enough for bridge.sh to read stdin, printf '{}', and background curl.
|
||||
const timeout = 5000;
|
||||
return {
|
||||
SessionStart: [{ hooks: [{ type: 'command', command: mkCmd('session-start'), timeout }] }],
|
||||
SessionEnd: [{ hooks: [{ type: 'command', command: mkCmd('session-end'), timeout }] }],
|
||||
BeforeTool: [{ matcher: '*', hooks: [{ type: 'command', command: mkCmd('before-tool'), timeout }] }],
|
||||
AfterTool: [{ matcher: '*', hooks: [{ type: 'command', command: mkCmd('after-tool'), timeout }] }],
|
||||
BeforeAgent: [{ hooks: [{ type: 'command', command: mkCmd('before-agent'), timeout }] }],
|
||||
AfterAgent: [{ hooks: [{ type: 'command', command: mkCmd('after-agent'), timeout }] }],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// server/adapters/gemini/index.ts
|
||||
import { IAdapter } from '../interface.js';
|
||||
import type { DirectoryEntry, ActiveSessionInfo, MessagesResult, CachedStatus } from '../interface.js';
|
||||
import { GeminiTmuxAdapter } from './gemini-tmux-adapter.js';
|
||||
import type { GeminiSessionState, GeminiHookBody } from './gemini-tmux-adapter.js';
|
||||
import { GeminiHookConfig } from './hook-config.js';
|
||||
import {
|
||||
getSessions, getSessionMessages, listDirectory,
|
||||
} from './json-store.js';
|
||||
import { GeminiTranscriptParser } from './transcript-parser.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';
|
||||
|
||||
|
||||
|
||||
const MODELS: ModelInfo[] = [
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'pro', label: 'Gemini Pro' },
|
||||
{ value: 'flash', label: 'Gemini Flash' },
|
||||
{ value: 'flash-lite', label: 'Flash Lite' },
|
||||
];
|
||||
|
||||
const PERMISSION_MODES: PermissionModeInfo[] = [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'auto_edit', label: 'Auto Edit' },
|
||||
{ value: 'yolo', label: 'YOLO' },
|
||||
{ value: 'plan', label: 'Plan' },
|
||||
];
|
||||
|
||||
export class GeminiAdapter extends IAdapter {
|
||||
static id: string = 'gemini';
|
||||
static displayName: string = 'Gemini CLI';
|
||||
static command: string = 'gemini';
|
||||
|
||||
private _tmux: GeminiTmuxAdapter;
|
||||
private _hookConfig: GeminiHookConfig;
|
||||
private _lastStatus: Map<string, CachedStatus>; // sessionId -> { contextPercent, model, cost }
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._tmux = new GeminiTmuxAdapter();
|
||||
this._hookConfig = new GeminiHookConfig();
|
||||
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', 'session-rekeyed',
|
||||
];
|
||||
for (const event of events) {
|
||||
this._tmux.on(event, (...args: unknown[]) => this.emit(event, ...args));
|
||||
}
|
||||
|
||||
// Don't forward status-update blindly — deduplicate first
|
||||
this._tmux.on('status-update', (sessionId: string, status: any) => {
|
||||
const prev = this._lastStatus.get(sessionId);
|
||||
if (prev &&
|
||||
prev.contextPercent === status.contextPercent &&
|
||||
prev.model === status.model &&
|
||||
prev.cost === status.cost) return;
|
||||
this._lastStatus.set(sessionId, status);
|
||||
this.emit('status-update', sessionId, status);
|
||||
});
|
||||
|
||||
// Clean up status 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 Gemini-specific hooks.
|
||||
* These are called by the Gemini CLI bridge script 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: GeminiHookBody) => 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/gemini
|
||||
|
||||
hookRoute(`${prefix}/session-start`, (body) => {
|
||||
this._tmux.handleSessionStart(body);
|
||||
});
|
||||
hookRoute(`${prefix}/session-end`, (body) => {
|
||||
this._tmux.handleSessionEnd(body);
|
||||
});
|
||||
hookRoute(`${prefix}/before-tool`, (body) => {
|
||||
this._tmux.handleBeforeTool(body);
|
||||
});
|
||||
hookRoute(`${prefix}/after-tool`, (body) => {
|
||||
this._tmux.handleAfterTool(body);
|
||||
});
|
||||
hookRoute(`${prefix}/before-agent`, (body) => {
|
||||
this._tmux.handleBeforeAgent(body);
|
||||
});
|
||||
hookRoute(`${prefix}/after-agent`, (body) => {
|
||||
this._tmux.handleAfterAgent(body);
|
||||
});
|
||||
}
|
||||
|
||||
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 }> { throw new Error('Gemini does not support attach'); }
|
||||
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 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 json-store, parse through transcript parser for getMessages
|
||||
async getSessions(dir?: string, limit?: number): Promise<SessionInfo[]> { return getSessions(dir, limit); }
|
||||
|
||||
async getMessages(sid: string, dir?: string): Promise<MessagesResult> {
|
||||
const { messages: rawMessages, lastModified } = getSessionMessages(sid, dir);
|
||||
if (rawMessages.length === 0) return { messages: [], lastModified };
|
||||
|
||||
// Parse raw Gemini messages through the transcript parser
|
||||
const parser = new GeminiTranscriptParser();
|
||||
const { messages } = parser.parse(rawMessages as import('../../stores/json-watcher.js').GeminiSessionMessage[]);
|
||||
return { messages, lastModified };
|
||||
}
|
||||
|
||||
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): GeminiSessionState | 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 []; }
|
||||
|
||||
getCapabilities(): AdapterCapabilities {
|
||||
return {
|
||||
supportsPlanMode: true,
|
||||
supportsPermissionModes: true,
|
||||
supportsInterrupt: true,
|
||||
supportsResume: true,
|
||||
supportsAttach: false,
|
||||
supportsStatusLine: false,
|
||||
supportsImages: true,
|
||||
supportsStreaming: true,
|
||||
maxContextWindow: 1_000_000,
|
||||
permissionModeType: 'toggle',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { extractUserText } from './message-utils.js';
|
||||
import type { DirectoryEntry } from '../interface.js';
|
||||
import type { SessionInfo } from '../../types/adapter.js';
|
||||
|
||||
// --- Constants ---
|
||||
export const GEMINI_DIR: string = join(homedir(), '.gemini');
|
||||
export const GEMINI_TMP_DIR: string = join(GEMINI_DIR, 'tmp');
|
||||
export const GEMINI_PROJECTS_FILE: string = join(GEMINI_DIR, 'projects.json');
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface GeminiProjectsFile {
|
||||
projects?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface GeminiSessionFile {
|
||||
sessionId?: string;
|
||||
startTime?: string;
|
||||
lastUpdated?: string;
|
||||
messages?: unknown[];
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
interface GeminiMessage {
|
||||
type?: string; // 'user' | 'gemini' | 'error' | 'info' | 'warning'
|
||||
content?: unknown;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function readProjectsJson(): GeminiProjectsFile {
|
||||
try {
|
||||
const raw = readFileSync(GEMINI_PROJECTS_FILE, 'utf-8');
|
||||
return JSON.parse(raw) as GeminiProjectsFile;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// --- Exported Functions ---
|
||||
|
||||
/**
|
||||
* Look up the Gemini project name for a given absolute directory path.
|
||||
* Reads ~/.gemini/projects.json which maps "/abs/path" → "project-name".
|
||||
*/
|
||||
export function getProjectName(dir: string): string | null {
|
||||
try {
|
||||
const data = readProjectsJson();
|
||||
return data.projects?.[dir] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all project directories under ~/.gemini/tmp/.
|
||||
* If dir is provided, only return the matching project directory.
|
||||
*/
|
||||
function getProjectDirs(dir?: string): Array<{ projectDir: string; cwd: string | null }> {
|
||||
if (dir) {
|
||||
const projectName = getProjectName(dir);
|
||||
if (!projectName) return [];
|
||||
return [{ projectDir: join(GEMINI_TMP_DIR, projectName), cwd: dir }];
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(GEMINI_TMP_DIR, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => {
|
||||
const projectDir = join(GEMINI_TMP_DIR, e.name);
|
||||
// Attempt to read .project_root for the cwd
|
||||
let cwd: string | null = null;
|
||||
try {
|
||||
cwd = readFileSync(join(projectDir, '.project_root'), 'utf-8').trim() || null;
|
||||
} catch {
|
||||
// no .project_root — cwd stays null
|
||||
}
|
||||
return { projectDir, cwd };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List sessions for a project (or all projects if dir is omitted).
|
||||
* Returns SessionInfo[] sorted by lastModified descending.
|
||||
*/
|
||||
export function getSessions(dir?: string, limit?: number): SessionInfo[] {
|
||||
const projectDirs = getProjectDirs(dir);
|
||||
const sessions: SessionInfo[] = [];
|
||||
|
||||
for (const { projectDir, cwd } of projectDirs) {
|
||||
const chatsDir = join(projectDir, 'chats');
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(chatsDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const jsonFiles = files.filter((f) => f.endsWith('.json'));
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
const filePath = join(chatsDir, file);
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(raw) as GeminiSessionFile;
|
||||
|
||||
const sessionId = data.sessionId ?? file.replace('.json', '');
|
||||
const lastModified = data.lastUpdated ?? data.startTime ?? null;
|
||||
|
||||
// Extract firstPrompt from first user message
|
||||
let firstPrompt: string | null = null;
|
||||
if (Array.isArray(data.messages)) {
|
||||
for (const msg of data.messages) {
|
||||
const m = msg as GeminiMessage;
|
||||
if (m.type === 'user' && m.content != null) {
|
||||
const text = extractUserText(m.content);
|
||||
if (text.trim()) {
|
||||
firstPrompt = text.slice(0, 200);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract model from last gemini message
|
||||
let model: string | null = null;
|
||||
if (Array.isArray(data.messages)) {
|
||||
for (let i = data.messages.length - 1; i >= 0; i--) {
|
||||
const m = data.messages[i] as GeminiMessage;
|
||||
if (m.type === 'gemini' && m.model) {
|
||||
model = m.model;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
sessionId,
|
||||
cwd,
|
||||
lastModified: lastModified ?? undefined,
|
||||
firstPrompt,
|
||||
model,
|
||||
});
|
||||
} catch {
|
||||
// skip malformed files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by lastModified descending, null-safe
|
||||
sessions.sort((a, b) => {
|
||||
const ta = a.lastModified ? new Date(a.lastModified).getTime() : 0;
|
||||
const tb = b.lastModified ? new Date(b.lastModified).getTime() : 0;
|
||||
return tb - ta;
|
||||
});
|
||||
|
||||
return limit ? sessions.slice(0, limit) : sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the absolute path of a session file by UUID across all project dirs.
|
||||
* Returns null if not found.
|
||||
*/
|
||||
export function findSessionFile(sessionId: string): string | null {
|
||||
let projectDirs: string[];
|
||||
try {
|
||||
const entries = readdirSync(GEMINI_TMP_DIR, { withFileTypes: true });
|
||||
projectDirs = entries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => join(GEMINI_TMP_DIR, e.name));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const projectDir of projectDirs) {
|
||||
const chatsDir = join(projectDir, 'chats');
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(chatsDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
const filePath = join(chatsDir, file);
|
||||
// Fast path: check if session UUID appears in filename before parsing
|
||||
if (file.includes(sessionId)) return filePath;
|
||||
// Slow path: parse JSON to check sessionId field
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(raw) as GeminiSessionFile;
|
||||
if (data.sessionId === sessionId) return filePath;
|
||||
} catch {
|
||||
// skip malformed files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all messages from a session file.
|
||||
* If dir is provided, search only in that project's chats dir.
|
||||
*/
|
||||
export function getSessionMessages(
|
||||
sessionId: string,
|
||||
dir?: string
|
||||
): { messages: unknown[]; lastModified: string | null } {
|
||||
// Determine candidate file paths
|
||||
let filePath: string | null = null;
|
||||
|
||||
if (dir) {
|
||||
const projectName = getProjectName(dir);
|
||||
if (projectName) {
|
||||
const chatsDir = join(GEMINI_TMP_DIR, projectName, 'chats');
|
||||
// Try exact match first, then scan
|
||||
try {
|
||||
const files = readdirSync(chatsDir);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
const fp = join(chatsDir, file);
|
||||
try {
|
||||
const raw = readFileSync(fp, 'utf-8');
|
||||
const data = JSON.parse(raw) as GeminiSessionFile;
|
||||
const fileSessionId = data.sessionId ?? file.replace('.json', '');
|
||||
if (fileSessionId === sessionId) {
|
||||
filePath = fp;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// chats dir not readable
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filePath = findSessionFile(sessionId);
|
||||
}
|
||||
|
||||
if (!filePath) return { messages: [], lastModified: null };
|
||||
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(raw) as GeminiSessionFile;
|
||||
const messages = Array.isArray(data.messages) ? data.messages : [];
|
||||
|
||||
let lastModified: string | null = null;
|
||||
try {
|
||||
const s = statSync(filePath);
|
||||
lastModified = s.mtime.toISOString();
|
||||
} catch {
|
||||
lastModified = data.lastUpdated ?? data.startTime ?? null;
|
||||
}
|
||||
|
||||
return { messages, lastModified };
|
||||
} catch {
|
||||
return { messages: [], lastModified: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List directory entries (non-hidden subdirectories) for the directory browser.
|
||||
* If path is omitted, defaults to the user's home directory.
|
||||
*/
|
||||
export function listDirectory(dirPath?: string): DirectoryEntry[] {
|
||||
const target = dirPath || homedir();
|
||||
try {
|
||||
const entries = readdirSync(target, { withFileTypes: true });
|
||||
const visible = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.'));
|
||||
|
||||
const dirs: DirectoryEntry[] = visible.map((entry) => {
|
||||
const fullPath = join(target, entry.name);
|
||||
let hasChildren = false;
|
||||
try {
|
||||
const children = readdirSync(fullPath, { withFileTypes: true });
|
||||
hasChildren = children.some((c) => c.isDirectory() && !c.name.startsWith('.'));
|
||||
} catch {
|
||||
// no access
|
||||
}
|
||||
return { name: entry.name, path: fullPath, hasChildren };
|
||||
});
|
||||
|
||||
return dirs.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ContentBlock } from '../claude/message-utils.js';
|
||||
|
||||
export type { ContentBlock };
|
||||
|
||||
/** A tool call embedded in a Gemini session message */
|
||||
export interface GeminiToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: unknown[];
|
||||
status: 'running' | 'success' | 'error' | 'cancelled';
|
||||
timestamp?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plain text from a user message's content.
|
||||
* Gemini user content is either an array of { text } objects or a plain string.
|
||||
*/
|
||||
export function extractUserText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((item): item is { text: string } => item != null && typeof item.text === 'string')
|
||||
.map((item) => item.text)
|
||||
.join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Alias — Gemini assistant content is always a string, but use same extractor for safety */
|
||||
export const extractGeminiText = extractUserText;
|
||||
|
||||
/**
|
||||
* Convert Gemini's embedded toolCalls array into standard tool_use + tool_result ContentBlock pairs.
|
||||
*
|
||||
* Each GeminiToolCall becomes:
|
||||
* - A tool_use block (id, name, input = args)
|
||||
* - Optionally a tool_result block if a result is present (tool_use_id, content, is_error)
|
||||
*/
|
||||
export function toolCallsToContentBlocks(toolCalls: GeminiToolCall[]): ContentBlock[] {
|
||||
const blocks: ContentBlock[] = [];
|
||||
|
||||
for (const tc of toolCalls) {
|
||||
// tool_use block
|
||||
blocks.push({
|
||||
type: 'tool_use',
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
input: tc.args,
|
||||
});
|
||||
|
||||
// tool_result block — only if result data is present
|
||||
if (tc.result !== undefined && tc.result !== null) {
|
||||
const isError = tc.status === 'error' || tc.status === 'cancelled';
|
||||
|
||||
// Extract the output or error string from the function response structure
|
||||
let resultContent = '';
|
||||
if (Array.isArray(tc.result) && tc.result.length > 0) {
|
||||
const firstResult = tc.result[0] as Record<string, unknown> | null;
|
||||
const functionResponse = firstResult?.functionResponse as Record<string, unknown> | undefined;
|
||||
const response = functionResponse?.response as Record<string, unknown> | undefined;
|
||||
if (isError) {
|
||||
resultContent = typeof response?.error === 'string'
|
||||
? response.error
|
||||
: JSON.stringify(response?.error ?? '');
|
||||
} else {
|
||||
resultContent = typeof response?.output === 'string'
|
||||
? response.output
|
||||
: JSON.stringify(response?.output ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: tc.id,
|
||||
content: resultContent,
|
||||
is_error: isError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// server/adapters/gemini/pane-monitor.ts
|
||||
//
|
||||
// Polls a tmux pane every 500ms to capture real-time streaming output from
|
||||
// the Gemini CLI.
|
||||
//
|
||||
// Detects:
|
||||
// 1. Streaming response text (new text since last poll)
|
||||
// 2. Thinking indicators (spinner / processing patterns)
|
||||
//
|
||||
// Note: Gemini already provides thinking content in JSON (thoughts[]), so
|
||||
// pane-level thinking detection is supplementary — it provides real-time
|
||||
// feedback before the JSON response is written to disk.
|
||||
//
|
||||
// Modelled after server/adapters/codex/pane-monitor.ts but with
|
||||
// Gemini-specific regex patterns. Patterns are conservative placeholders
|
||||
// that will be refined through empirical testing with the actual Gemini CLI.
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
/** Minimal interface for the tmux manager dependency */
|
||||
interface TmuxCapture {
|
||||
capturePane(windowId: string, lines?: number): Promise<string>;
|
||||
}
|
||||
|
||||
/** Thinking indicator detected from pane content */
|
||||
export interface ThinkingInfo {
|
||||
text: string;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeminiPaneMonitor — polls a tmux pane to detect streaming text and
|
||||
* thinking indicators from the Gemini CLI.
|
||||
*
|
||||
* Events emitted via the injected EventEmitter:
|
||||
* - 'streaming-text' (sessionId, newText)
|
||||
* - 'thinking' (sessionId, { text, detail })
|
||||
*/
|
||||
export class GeminiPaneMonitor {
|
||||
private sessionId: string;
|
||||
private windowId: string;
|
||||
private tmux: TmuxCapture;
|
||||
private emitter: EventEmitter;
|
||||
private interval: ReturnType<typeof setInterval> | null = null;
|
||||
private _lastContent: string = '';
|
||||
private _lastResponseText: string = '';
|
||||
|
||||
constructor(
|
||||
sessionId: string,
|
||||
windowId: string,
|
||||
tmuxManager: TmuxCapture,
|
||||
emitter: EventEmitter,
|
||||
) {
|
||||
this.sessionId = sessionId;
|
||||
this.windowId = windowId;
|
||||
this.tmux = tmuxManager;
|
||||
this.emitter = emitter;
|
||||
}
|
||||
|
||||
/** Begin polling the tmux pane at 500ms intervals */
|
||||
start(): void {
|
||||
if (this.interval) return;
|
||||
this.interval = setInterval(() => this._poll(), 500);
|
||||
}
|
||||
|
||||
/** Stop polling and clear the interval */
|
||||
stop(): void {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Force an immediate poll (useful on hook receipt) */
|
||||
async pollNow(): Promise<void> {
|
||||
await this._poll();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async _poll(): Promise<void> {
|
||||
try {
|
||||
const content = await this.tmux.capturePane(this.windowId);
|
||||
if (content === this._lastContent) return;
|
||||
this._lastContent = content;
|
||||
|
||||
// 1. Check for thinking indicator
|
||||
const thinking = detectThinking(content);
|
||||
if (thinking) {
|
||||
this.emitter.emit('thinking', this.sessionId, thinking);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Extract streaming response text
|
||||
const text = extractResponseText(content);
|
||||
if (text && text !== this._lastResponseText) {
|
||||
this._lastResponseText = text;
|
||||
this.emitter.emit('streaming-text', this.sessionId, text);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — tmux window may have been killed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Detection functions (exported for unit testing)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Detect Gemini thinking/processing indicators.
|
||||
*
|
||||
* Gemini CLI shows various spinner/processing patterns while reasoning.
|
||||
* In non-alt-screen mode these appear as inline text in the pane.
|
||||
*
|
||||
* Placeholder patterns — will be refined through empirical testing:
|
||||
* - "Thinking..." text (Gemini's native thinking label)
|
||||
* - Spinner characters (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ braille spinner set)
|
||||
* - "Generating..." or processing indicators
|
||||
*/
|
||||
export function detectThinking(content: string): ThinkingInfo | null {
|
||||
const lines = content.split('\n');
|
||||
// Only check the tail of the pane (last 15 lines)
|
||||
const tail = lines.slice(-15);
|
||||
|
||||
for (const line of tail) {
|
||||
// Skip completion/summary lines
|
||||
if (/completed|finished|done|exited/i.test(line)) continue;
|
||||
|
||||
// Pattern 1: Braille spinner followed by descriptive text
|
||||
// e.g. "⠙ Thinking..." or "⠹ Generating..."
|
||||
const brailleMatch = line.match(/^\s*([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏])\s+(.+?)\s*$/);
|
||||
if (brailleMatch) {
|
||||
return { text: brailleMatch[2]!, detail: null };
|
||||
}
|
||||
|
||||
// Pattern 2: Explicit "Thinking..." or "Generating..." text
|
||||
// Gemini CLI commonly shows "Thinking..." during reasoning
|
||||
const thinkingMatch = line.match(
|
||||
/^\s*(Thinking|Generating|Processing|Working)(\.\.\.)?\s*(?:\((.+?)\))?\s*$/i,
|
||||
);
|
||||
if (thinkingMatch) {
|
||||
return {
|
||||
text: `${thinkingMatch[1]}...`,
|
||||
detail: thinkingMatch[3] || null,
|
||||
};
|
||||
}
|
||||
|
||||
// Pattern 3: Braille spinner on its own (Gemini may render bare spinner)
|
||||
const bareSpinner = line.match(/^\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s*$/);
|
||||
if (bareSpinner) {
|
||||
return { text: 'Thinking...', detail: null };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the current streaming response text from pane content.
|
||||
*
|
||||
* Gemini CLI writes responses inline. We look for text after the last
|
||||
* user input marker and collect lines until we hit a boundary indicator.
|
||||
*
|
||||
* Placeholder patterns — will be refined through empirical testing:
|
||||
* - User input prompt: ">" or "❯" followed by user text
|
||||
* - Response boundary: horizontal rules, new prompts, spinner indicators
|
||||
*/
|
||||
export function extractResponseText(content: string): string {
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find the LAST user prompt line — responses appear after it
|
||||
let lastUserPrompt = -1;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i]!;
|
||||
// Gemini user prompt patterns (conservative):
|
||||
// - ">" or "❯" at start of line followed by user text
|
||||
// - "user:" prefix
|
||||
if (/^\s*[>❯]\s+\S/.test(line) || /^\s*user:\s/i.test(line)) {
|
||||
lastUserPrompt = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUserPrompt === -1) return '';
|
||||
|
||||
// Collect response lines after the user prompt
|
||||
// Skip the prompt line itself and any blank lines immediately after
|
||||
let responseStart = lastUserPrompt + 1;
|
||||
while (responseStart < lines.length && lines[responseStart]!.trim() === '') {
|
||||
responseStart++;
|
||||
}
|
||||
|
||||
if (responseStart >= lines.length) return '';
|
||||
|
||||
const responseLines: string[] = [];
|
||||
for (let i = responseStart; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
|
||||
// Stop at boundary markers
|
||||
if (
|
||||
// Horizontal rules
|
||||
/^[─━═\-]{5,}/.test(line.trim()) ||
|
||||
// New user prompt
|
||||
/^\s*[>❯]\s+\S/.test(line) ||
|
||||
// Spinner/thinking indicators (braille set)
|
||||
/^\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s*/.test(line)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
responseLines.push(line);
|
||||
}
|
||||
|
||||
return responseLines.join('\n').trim();
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { ContentBlock } from '../claude/message-utils.js';
|
||||
import type { GeminiSessionMessage } from '../../stores/json-watcher.js';
|
||||
import {
|
||||
extractUserText,
|
||||
extractGeminiText,
|
||||
toolCallsToContentBlocks,
|
||||
} from './message-utils.js';
|
||||
import type { GeminiToolCall } from './message-utils.js';
|
||||
|
||||
/** Parsed message ready for the frontend */
|
||||
export interface ParsedMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'plan';
|
||||
/** Always ContentBlock[] — never a plain string, for consistency with Claude/Codex */
|
||||
content: ContentBlock[];
|
||||
adapter?: string;
|
||||
senderClientId?: string | null;
|
||||
}
|
||||
|
||||
/** Result returned by parse() */
|
||||
export interface ParseResult {
|
||||
messages: ParsedMessage[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** Model/token status extracted from an info message */
|
||||
export interface StatusInfo {
|
||||
model: string | null;
|
||||
tokens: Record<string, number> | null;
|
||||
}
|
||||
|
||||
/** A single thought entry from a gemini message */
|
||||
export interface ThoughtEntry {
|
||||
subject: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export class GeminiTranscriptParser {
|
||||
/** Monotonically increasing message index — NOT reset between parse() calls */
|
||||
private _msgIndex: number = 0;
|
||||
|
||||
/**
|
||||
* Parse an incremental batch of GeminiSessionMessages into frontend-ready ParsedMessages.
|
||||
*
|
||||
* NOTE: _msgIndex is intentionally NOT reset here. parse() is called incrementally via
|
||||
* JsonWatcher.onNewMessages(). Resetting would restart IDs at msg-0, causing React key
|
||||
* collisions across batches.
|
||||
*/
|
||||
parse(messages: GeminiSessionMessage[]): ParseResult {
|
||||
const result: ParsedMessage[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
switch (msg.type) {
|
||||
case 'user': {
|
||||
const parsed = this._parseUserMessage(msg);
|
||||
if (parsed) result.push(parsed);
|
||||
break;
|
||||
}
|
||||
case 'gemini': {
|
||||
const parsed = this._parseGeminiMessage(msg);
|
||||
if (parsed) result.push(parsed);
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
// Collect errors to emit as session-error events; don't add as chat messages
|
||||
const errText = extractUserText(msg.content) || String(msg.content ?? 'Unknown error');
|
||||
errors.push(errText);
|
||||
break;
|
||||
}
|
||||
case 'info':
|
||||
// Info messages carry metadata (model, tokens) — skip as chat messages
|
||||
break;
|
||||
default:
|
||||
// Unknown type — skip silently
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { messages: result, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract thought entries from a gemini message's thoughts array.
|
||||
* Returns an empty array if none are present.
|
||||
*/
|
||||
static extractThoughts(msg: GeminiSessionMessage): ThoughtEntry[] {
|
||||
if (!Array.isArray(msg.thoughts) || msg.thoughts.length === 0) return [];
|
||||
return msg.thoughts
|
||||
.filter((t): t is Record<string, unknown> => t != null && typeof t === 'object')
|
||||
.map((t) => ({
|
||||
subject: typeof t['subject'] === 'string' ? t['subject'] : '',
|
||||
description: typeof t['description'] === 'string' ? t['description'] : '',
|
||||
timestamp: typeof t['timestamp'] === 'string' ? t['timestamp'] : (msg.timestamp ?? ''),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract model/token status from an info-type message.
|
||||
* Returns null if the message carries no relevant status data.
|
||||
*/
|
||||
static extractStatus(msg: GeminiSessionMessage): StatusInfo | null {
|
||||
const model = msg.model ?? null;
|
||||
let tokens: Record<string, number> | null = null;
|
||||
|
||||
if (msg.tokens && typeof msg.tokens === 'object') {
|
||||
const raw = msg.tokens as Record<string, unknown>;
|
||||
const parsed: Record<string, number> = {};
|
||||
for (const [key, val] of Object.entries(raw)) {
|
||||
if (typeof val === 'number') parsed[key] = val;
|
||||
}
|
||||
if (Object.keys(parsed).length > 0) tokens = parsed;
|
||||
}
|
||||
|
||||
if (model === null && tokens === null) return null;
|
||||
return { model, tokens };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private _parseUserMessage(msg: GeminiSessionMessage): ParsedMessage | null {
|
||||
const text = extractUserText(msg.content);
|
||||
if (!text.trim()) return null;
|
||||
|
||||
const content: ContentBlock[] = [{ type: 'text', text }];
|
||||
return {
|
||||
id: `msg-${this._msgIndex++}`,
|
||||
role: 'user',
|
||||
content,
|
||||
adapter: 'gemini',
|
||||
};
|
||||
}
|
||||
|
||||
private _parseGeminiMessage(msg: GeminiSessionMessage): ParsedMessage | null {
|
||||
const text = extractGeminiText(msg.content);
|
||||
const toolBlocks = this._extractToolBlocks(msg);
|
||||
|
||||
// Skip completely empty messages
|
||||
if (!text.trim() && toolBlocks.length === 0) return null;
|
||||
|
||||
const content: ContentBlock[] = [];
|
||||
|
||||
// Text block first (if present)
|
||||
if (text.trim()) {
|
||||
content.push({ type: 'text', text });
|
||||
}
|
||||
|
||||
// Tool call blocks after the text
|
||||
content.push(...toolBlocks);
|
||||
|
||||
return {
|
||||
id: `msg-${this._msgIndex++}`,
|
||||
role: 'assistant',
|
||||
content,
|
||||
adapter: 'gemini',
|
||||
};
|
||||
}
|
||||
|
||||
private _extractToolBlocks(msg: GeminiSessionMessage): ContentBlock[] {
|
||||
if (!Array.isArray(msg.toolCalls) || msg.toolCalls.length === 0) return [];
|
||||
|
||||
const toolCalls = (msg.toolCalls as unknown[]).filter(
|
||||
(tc): tc is GeminiToolCall =>
|
||||
tc != null &&
|
||||
typeof tc === 'object' &&
|
||||
typeof (tc as Record<string, unknown>)['id'] === 'string' &&
|
||||
typeof (tc as Record<string, unknown>)['name'] === 'string',
|
||||
);
|
||||
|
||||
return toolCallsToContentBlocks(toolCalls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// server/adapters/init.ts
|
||||
import { register, getAdapterConfig } from './registry.js';
|
||||
|
||||
const LOADERS: Record<string, () => Promise<any>> = {
|
||||
claude: () => import('./claude/index.js').then(m => m.ClaudeAdapter),
|
||||
codex: () => import('./codex/index.js').then(m => m.CodexAdapter),
|
||||
gemini: () => import('./gemini/index.js').then(m => m.GeminiAdapter),
|
||||
};
|
||||
|
||||
const { enabledAdapters } = getAdapterConfig();
|
||||
|
||||
for (const id of enabledAdapters) {
|
||||
const loader = LOADERS[id];
|
||||
if (!loader) { console.warn(`[init] Unknown adapter: ${id}`); continue; }
|
||||
try {
|
||||
const AdapterClass = await loader();
|
||||
register(AdapterClass);
|
||||
} catch (err) {
|
||||
console.warn(`[init] Failed to load adapter ${id}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// server/adapters/interface.ts
|
||||
import { EventEmitter } from 'events';
|
||||
import type { Express } from 'express';
|
||||
import type { QueryOptions, PermissionBehavior, PermissionMode, ChatMessage } from '../types/messages.js';
|
||||
import type {
|
||||
AdapterCapabilities, SessionInfo, ModelInfo,
|
||||
PermissionModeInfo, EffortLevelInfo, ReconnectState,
|
||||
} from '../types/adapter.js';
|
||||
|
||||
/** Threshold for switching from sendKeys (character-by-character) to pasteBuffer (bulk paste) */
|
||||
export const PASTE_THRESHOLD = 500;
|
||||
|
||||
/** Check if text should be sent via pasteBuffer instead of sendKeys */
|
||||
export function isLargeContent(text: string): boolean {
|
||||
return text.length > PASTE_THRESHOLD || text.includes('\n');
|
||||
}
|
||||
|
||||
/** Cached session status for deduplication and reconnect */
|
||||
export interface CachedStatus {
|
||||
contextPercent: number | null;
|
||||
model: string | null;
|
||||
cost: number | null;
|
||||
}
|
||||
|
||||
/** Directory entry returned by listDirectory */
|
||||
export interface DirectoryEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
hasChildren: boolean;
|
||||
}
|
||||
|
||||
/** Active session info returned by getActiveSessions */
|
||||
export interface ActiveSessionInfo {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
adapter: string;
|
||||
permissionMode: string;
|
||||
lastActivity: number | null;
|
||||
hasClients: boolean;
|
||||
hasDesktop: boolean;
|
||||
isNonInteractive: boolean;
|
||||
firstPrompt: string | null;
|
||||
}
|
||||
|
||||
/** Messages result from getMessages */
|
||||
export interface MessagesResult {
|
||||
messages: unknown[];
|
||||
lastModified: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* IAdapter — Base class for CLI agent adapters.
|
||||
*
|
||||
* Each adapter is a self-contained plugin that:
|
||||
* - Manages CLI process lifecycle (start, resume, attach, destroy)
|
||||
* - Registers its own HTTP hook routes via setup(app)
|
||||
* - Owns its session store (getSessions, getMessages)
|
||||
* - Emits standardized events for the session manager
|
||||
*
|
||||
* Events (all emit sessionId as first arg):
|
||||
* streaming-text(sessionId, text)
|
||||
* thinking(sessionId, { text, detail })
|
||||
* tool-start(sessionId, { toolId, toolName, input })
|
||||
* tool-done(sessionId, { toolId, toolName, result })
|
||||
* tool-updates(sessionId, toolsMap)
|
||||
* new-messages(sessionId, messages[])
|
||||
* session-idle(sessionId)
|
||||
* permission-request(sessionId, { requestId, toolName, input })
|
||||
* ask-question(sessionId, { requestId, toolName, input })
|
||||
* status-update(sessionId, { contextPercent, model, cost })
|
||||
*/
|
||||
export class IAdapter extends EventEmitter {
|
||||
/** Unique adapter identifier (e.g. 'claude', 'codex') */
|
||||
static id: string = '';
|
||||
/** Human-readable name (e.g. 'Claude Code') */
|
||||
static displayName: string = '';
|
||||
/** CLI binary name for auto-detection (e.g. 'claude') */
|
||||
static command: string = '';
|
||||
|
||||
protected _clientChecker: ((sessionId: string) => boolean) | null = null;
|
||||
|
||||
/**
|
||||
* Register adapter-specific HTTP routes and configure CLI hooks.
|
||||
* Called once during server startup.
|
||||
*/
|
||||
setup(app: Express): void { throw new Error('Not implemented: setup'); }
|
||||
|
||||
/**
|
||||
* 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 }> { throw new Error('Not implemented: startSession'); }
|
||||
async resumeSession(sessionId: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { throw new Error('Not implemented: resumeSession'); }
|
||||
async attachSession(sessionId: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { throw new Error('Not implemented: attachSession'); }
|
||||
async destroySession(sessionId: string): Promise<void> { throw new Error('Not implemented: destroySession'); }
|
||||
|
||||
// --- Messaging ---
|
||||
|
||||
async sendMessage(sessionId: string, text: string, options?: QueryOptions): Promise<void> { throw new Error('Not implemented: sendMessage'); }
|
||||
async respondPlan(sessionId: string, optionIndex: number, text?: string): Promise<void> {}
|
||||
async interrupt(sessionId: string): Promise<void> { throw new Error('Not implemented: interrupt'); }
|
||||
async switchModel(sessionId: string, model: string): Promise<void> {}
|
||||
flushMessages(sessionId: string): void {}
|
||||
syncWatcherPosition(sessionId: string): void {}
|
||||
getReconnectState(sessionId: string): ReconnectState { return { tools: {} as Record<string, any>, pendingRequests: [] }; }
|
||||
|
||||
// --- Session Store ---
|
||||
|
||||
async getSessions(dir?: string, limit?: number): Promise<SessionInfo[]> { throw new Error('Not implemented: getSessions'); }
|
||||
async getMessages(sessionId: string, dir?: string): Promise<MessagesResult> { throw new Error('Not implemented: getMessages'); }
|
||||
async listDirectory(path?: string): Promise<DirectoryEntry[]> { throw new Error('Not implemented: listDirectory'); }
|
||||
|
||||
// --- Permissions ---
|
||||
|
||||
async switchPermissionMode(sessionId: string, mode: string): Promise<boolean> { return false; }
|
||||
respondPermission(requestId: string, behavior: PermissionBehavior): void {}
|
||||
async respondQuestion(requestId: string, answer: string): Promise<void> {}
|
||||
releaseAllPending(sessionId: string): void {}
|
||||
resolveAllPendingAs(sessionId: string, behavior: PermissionBehavior): void {}
|
||||
|
||||
// --- Query ---
|
||||
|
||||
getSession(sessionId: string): unknown { return null; }
|
||||
getLastStatus(sessionId: string): { contextPercent: number | null; model: string | null; cost: number | null } | null { return null; }
|
||||
getActiveSessions(): ActiveSessionInfo[] { return []; }
|
||||
async hasActiveWindow(sessionId: string): Promise<boolean> { return false; }
|
||||
|
||||
// --- Capabilities ---
|
||||
|
||||
getModels(): ModelInfo[] { return []; }
|
||||
getPermissionModes(): PermissionModeInfo[] { return []; }
|
||||
getEffortLevels(): EffortLevelInfo[] { return []; }
|
||||
getEffortLabel(): string { return 'Effort'; }
|
||||
|
||||
getCapabilities(): AdapterCapabilities {
|
||||
return {
|
||||
supportsPlanMode: false,
|
||||
supportsPermissionModes: false,
|
||||
supportsInterrupt: false,
|
||||
supportsResume: false,
|
||||
supportsAttach: false,
|
||||
supportsStatusLine: false,
|
||||
supportsImages: false,
|
||||
supportsStreaming: true,
|
||||
maxContextWindow: 0,
|
||||
};
|
||||
}
|
||||
|
||||
getHookPrefix(): string {
|
||||
return `/api/hooks/${(this.constructor as any).id}`;
|
||||
}
|
||||
|
||||
// --- Hooks ---
|
||||
|
||||
/** Install adapter-specific hooks (e.g., write to CLI settings). No server needed. */
|
||||
installHooks(): void {}
|
||||
/** Remove adapter-specific hooks. No server needed. */
|
||||
uninstallHooks(): void {}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
/** Called on server shutdown to clean up external config (e.g. CLI hooks). */
|
||||
async cleanup(): Promise<void> {}
|
||||
|
||||
/** Check if a session is actively processing a request. */
|
||||
isProcessing(sessionId: string): boolean { return false; }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// server/adapters/registry.ts
|
||||
import { execFileSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import type { Express } from 'express';
|
||||
import type { IAdapter } from './interface.js';
|
||||
import type { AdapterInfo } from '../types/adapter.js';
|
||||
|
||||
/** Constructor type for adapter classes that extend IAdapter */
|
||||
interface AdapterConstructor {
|
||||
new (): IAdapter;
|
||||
id: string;
|
||||
displayName: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
const configPath = path.join(os.homedir(), '.clawtap', 'config.json');
|
||||
let userConfig: { defaultAdapter?: string; adapters?: Record<string, { enabled: boolean }> } = {};
|
||||
try { userConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')); } catch {}
|
||||
export const DEFAULT_ADAPTER: string = userConfig.defaultAdapter || 'claude';
|
||||
|
||||
/** Return adapter config parsed from ~/.clawtap/config.json */
|
||||
export function getAdapterConfig(): { defaultAdapter: string; enabledAdapters: string[] } {
|
||||
// If no adapters config, enable all known adapters by default.
|
||||
// Registry's listAvailable() will check `which <command>` for actual availability.
|
||||
const enabledAdapters = userConfig.adapters
|
||||
? Object.entries(userConfig.adapters).filter(([, v]) => v.enabled).map(([k]) => k)
|
||||
: ['claude', 'codex', 'gemini'];
|
||||
return { defaultAdapter: DEFAULT_ADAPTER, enabledAdapters };
|
||||
}
|
||||
|
||||
const adapters: Map<string, IAdapter> = new Map(); // id → adapter instance
|
||||
let cachedAvailable: AdapterInfo[] | null = null; // cached result of listAvailable()
|
||||
|
||||
export function register(AdapterClass: AdapterConstructor): IAdapter {
|
||||
const instance = new AdapterClass();
|
||||
adapters.set(AdapterClass.id, instance);
|
||||
cachedAvailable = null; // invalidate cache
|
||||
return instance;
|
||||
}
|
||||
|
||||
export function get(id: string): IAdapter | undefined {
|
||||
return adapters.get(id);
|
||||
}
|
||||
|
||||
export function getDefault(): IAdapter | null {
|
||||
return adapters.get(DEFAULT_ADAPTER) || adapters.values().next().value || null;
|
||||
}
|
||||
|
||||
export function listAvailable(): AdapterInfo[] {
|
||||
if (cachedAvailable) return cachedAvailable;
|
||||
cachedAvailable = [...adapters.values()].map(adapter => {
|
||||
const Cls = adapter.constructor as unknown as AdapterConstructor;
|
||||
let available = false;
|
||||
try {
|
||||
execFileSync('which', [Cls.command], { stdio: 'ignore' });
|
||||
available = true;
|
||||
} catch {}
|
||||
return {
|
||||
id: Cls.id,
|
||||
displayName: Cls.displayName,
|
||||
available,
|
||||
capabilities: adapter.getCapabilities(),
|
||||
};
|
||||
});
|
||||
return cachedAvailable;
|
||||
}
|
||||
|
||||
export function initAll(app: Express): Map<string, IAdapter> {
|
||||
for (const [, adapter] of adapters) {
|
||||
adapter.setup(app);
|
||||
}
|
||||
listAvailable(); // Pre-cache — sync execFileSync runs once at startup, not per-request
|
||||
return adapters;
|
||||
}
|
||||
|
||||
export function getAll(): Map<string, IAdapter> {
|
||||
return adapters;
|
||||
}
|
||||
|
||||
export async function cleanupAll(): Promise<void> {
|
||||
for (const [, adapter] of adapters) {
|
||||
await adapter.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { unlinkSync } from 'fs';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const TMUX = 'tmux';
|
||||
const SESSION_NAME = 'clawtap';
|
||||
|
||||
/** A tmux window entry from listWindows */
|
||||
export interface TmuxWindow {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export class TmuxManager {
|
||||
async ensureSession(): Promise<void> {
|
||||
try {
|
||||
await exec(TMUX, ['has-session', '-t', SESSION_NAME]);
|
||||
} catch {
|
||||
await exec(TMUX, ['new-session', '-d', '-s', SESSION_NAME, '-n', 'main']);
|
||||
}
|
||||
}
|
||||
|
||||
async createWindow(name: string, cwd: string, command: string): Promise<string> {
|
||||
await this.ensureSession();
|
||||
await exec(TMUX, [
|
||||
'new-window', '-t', SESSION_NAME, '-n', name, '-c', cwd, command
|
||||
]);
|
||||
const { stdout } = await exec(TMUX, [
|
||||
'list-windows', '-t', SESSION_NAME, '-F', '#{window_name}\t#{window_id}'
|
||||
]);
|
||||
const line = stdout.trim().split('\n').find(l => l.startsWith(name + '\t'));
|
||||
return line ? line.split('\t')[1]! : name;
|
||||
}
|
||||
|
||||
async sendKeys(windowId: string, text: string, enter: boolean = true): Promise<void> {
|
||||
const target = `${SESSION_NAME}:${windowId}`;
|
||||
await exec(TMUX, ['send-keys', '-t', target, '-l', text]);
|
||||
if (enter) {
|
||||
await exec(TMUX, ['send-keys', '-t', target, 'Enter']);
|
||||
}
|
||||
}
|
||||
|
||||
async sendControl(windowId: string, key: string): Promise<void> {
|
||||
const target = `${SESSION_NAME}:${windowId}`;
|
||||
await exec(TMUX, ['send-keys', '-t', target, key]);
|
||||
}
|
||||
|
||||
async pasteBuffer(windowId: string, content: string, sendEnter: boolean = true): Promise<void> {
|
||||
const id = randomUUID();
|
||||
const tmpFile = `/tmp/clawtap-buf-${id}.txt`;
|
||||
const bufName = `ct-${id.slice(0, 8)}`;
|
||||
await writeFile(tmpFile, content);
|
||||
const target = `${SESSION_NAME}:${windowId}`;
|
||||
try {
|
||||
await exec(TMUX, ['load-buffer', '-b', bufName, tmpFile]);
|
||||
await exec(TMUX, ['paste-buffer', '-b', bufName, '-t', target]);
|
||||
if (sendEnter) {
|
||||
await exec(TMUX, ['send-keys', '-t', target, 'Enter']);
|
||||
}
|
||||
} finally {
|
||||
exec(TMUX, ['delete-buffer', '-b', bufName]).catch(() => {});
|
||||
try { unlinkSync(tmpFile); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async capturePane(windowId: string, lines: number = 200): Promise<string> {
|
||||
const target = `${SESSION_NAME}:${windowId}`;
|
||||
const { stdout } = await exec(TMUX, [
|
||||
'capture-pane', '-t', target, '-p', '-S', `-${lines}`
|
||||
]);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async killWindow(windowId: string): Promise<void> {
|
||||
const target = `${SESSION_NAME}:${windowId}`;
|
||||
try { await exec(TMUX, ['kill-window', '-t', target]); } catch {}
|
||||
}
|
||||
|
||||
async renameWindow(windowId: string, newName: string): Promise<void> {
|
||||
const target = `${SESSION_NAME}:${windowId}`;
|
||||
await exec(TMUX, ['rename-window', '-t', target, newName]);
|
||||
}
|
||||
|
||||
async killSession(): Promise<void> {
|
||||
try { await exec(TMUX, ['kill-session', '-t', SESSION_NAME]); } catch {}
|
||||
}
|
||||
|
||||
async listWindows(): Promise<TmuxWindow[]> {
|
||||
try {
|
||||
const { stdout } = await exec(TMUX, [
|
||||
'list-windows', '-t', SESSION_NAME, '-F',
|
||||
'#{window_id}\t#{window_name}\t#{pane_current_command}\t#{pane_current_path}'
|
||||
]);
|
||||
return stdout.trim().split('\n').filter(Boolean).map(line => {
|
||||
const [id, name, command, cwd] = line.split('\t');
|
||||
return { id: id!, name: name!, command: command!, cwd: cwd! };
|
||||
});
|
||||
} catch { return []; }
|
||||
}
|
||||
}
|
||||
|
||||
export const tmuxManager = new TmuxManager();
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import type { AppConfig } from './config.js';
|
||||
import { rateLimit } from './db.js';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
const JWT_EXPIRY = '7d';
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const WINDOW_SECONDS = 60;
|
||||
|
||||
let jwtSecret: string;
|
||||
let passwordHash: string;
|
||||
|
||||
// Must be called before any other function
|
||||
export async function initAuth(config: AppConfig): Promise<void> {
|
||||
const { password } = config;
|
||||
const AUTH_FILE = config.paths.auth;
|
||||
|
||||
// Load or generate JWT secret
|
||||
if (existsSync(AUTH_FILE)) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(AUTH_FILE, 'utf8'));
|
||||
jwtSecret = data.jwtSecret;
|
||||
} catch {}
|
||||
}
|
||||
if (!jwtSecret) {
|
||||
const { randomBytes } = await import('crypto');
|
||||
jwtSecret = randomBytes(64).toString('hex');
|
||||
writeFileSync(AUTH_FILE, JSON.stringify({ jwtSecret }), { mode: 0o600 });
|
||||
}
|
||||
|
||||
passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
export async function login(
|
||||
password: string,
|
||||
ip: string
|
||||
): Promise<{ token: string } | { error: string; status: number }> {
|
||||
// Check rate limit from SQLite
|
||||
const recentAttempts = rateLimit.countRecent(ip, WINDOW_SECONDS);
|
||||
if (recentAttempts >= MAX_ATTEMPTS) {
|
||||
return { error: 'Too many login attempts. Try again later.', status: 429 };
|
||||
}
|
||||
|
||||
// Record this attempt
|
||||
rateLimit.record(ip);
|
||||
|
||||
const valid = await bcrypt.compare(password, passwordHash);
|
||||
if (!valid) {
|
||||
return { error: 'Invalid password', status: 401 };
|
||||
}
|
||||
|
||||
// Periodically clean up old attempts
|
||||
rateLimit.cleanup();
|
||||
|
||||
const token = jwt.sign({ user: 'admin' }, jwtSecret, { expiresIn: JWT_EXPIRY });
|
||||
return { token };
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return jwt.verify(token, jwtSecret) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ error: 'No token provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
if (!decoded) {
|
||||
res.status(403).json({ error: 'Invalid token' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-refresh: if past halfway, issue new token
|
||||
if (decoded.exp && decoded.iat) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const halfLife = ((decoded.exp as number) - (decoded.iat as number)) / 2;
|
||||
if (now > (decoded.iat as number) + halfLife) {
|
||||
const newToken = jwt.sign({ user: 'admin' }, jwtSecret, { expiresIn: JWT_EXPIRY });
|
||||
res.setHeader('X-Refreshed-Token', newToken);
|
||||
}
|
||||
}
|
||||
|
||||
(req as Request & { user: Record<string, unknown> }).user = decoded;
|
||||
next();
|
||||
}
|
||||
|
||||
export function verifyWebSocketToken(token: string): boolean {
|
||||
return verifyToken(token) !== null;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const CLAWTAP_DIR = path.join(os.homedir(), '.clawtap');
|
||||
|
||||
export interface AppConfig {
|
||||
password: string;
|
||||
port: number;
|
||||
clawtapDir: string;
|
||||
https: { cert: Buffer; key: Buffer } | null;
|
||||
transcription: { provider: 'whisper'; apiKey: string } | null;
|
||||
gitBranch: string;
|
||||
paths: {
|
||||
auth: string;
|
||||
vapidKeys: string;
|
||||
pushSubs: string;
|
||||
pid: string;
|
||||
uploads: string;
|
||||
db: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
const password = process.env.CLAUDE_UI_PASSWORD;
|
||||
if (!password) {
|
||||
throw new Error(
|
||||
'CLAUDE_UI_PASSWORD is required.\n' +
|
||||
'Set it and try again:\n' +
|
||||
' export CLAUDE_UI_PASSWORD=your-password'
|
||||
);
|
||||
}
|
||||
|
||||
const port = parseInt(process.env.PORT || '', 10) || 3456;
|
||||
|
||||
const certPath = path.join(CLAWTAP_DIR, 'cert.pem');
|
||||
const keyPath = path.join(CLAWTAP_DIR, 'key.pem');
|
||||
const httpsConfig = (fs.existsSync(certPath) && fs.existsSync(keyPath))
|
||||
? { cert: fs.readFileSync(certPath), key: fs.readFileSync(keyPath) }
|
||||
: null;
|
||||
|
||||
const transcription = process.env.OPENAI_API_KEY
|
||||
? { provider: 'whisper' as const, apiKey: process.env.OPENAI_API_KEY }
|
||||
: null;
|
||||
|
||||
const config: AppConfig = {
|
||||
password,
|
||||
port,
|
||||
clawtapDir: CLAWTAP_DIR,
|
||||
https: httpsConfig,
|
||||
transcription,
|
||||
gitBranch: process.env.GIT_BRANCH || 'unknown',
|
||||
paths: {
|
||||
auth: path.join(os.homedir(), '.clawtap-auth.json'),
|
||||
vapidKeys: path.join(CLAWTAP_DIR, 'vapid-keys.json'),
|
||||
pushSubs: path.join(CLAWTAP_DIR, 'push-subscriptions.json'),
|
||||
pid: path.join(CLAWTAP_DIR, 'server.pid'),
|
||||
uploads: path.join(os.tmpdir(), 'clawtap-uploads'),
|
||||
db: path.join(CLAWTAP_DIR, 'clawtap.db'),
|
||||
},
|
||||
};
|
||||
|
||||
printFeatureStatus(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
function printFeatureStatus(config: AppConfig): void {
|
||||
const features: [string, string][] = [
|
||||
['HTTPS', config.https ? '✓ enabled' : '✗ disabled (no certs)'],
|
||||
['Voice Transcription', config.transcription ? `✓ ${config.transcription.provider}` : '✗ disabled (no OPENAI_API_KEY)'],
|
||||
['Port', String(config.port)],
|
||||
];
|
||||
|
||||
console.log('\n ClawTap Configuration:');
|
||||
for (const [name, status] of features) {
|
||||
console.log(` ${name}: ${status}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import type BetterSqlite3 from 'better-sqlite3';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import type { AppConfig } from './config.js';
|
||||
|
||||
let db: BetterSqlite3.Database | null = null;
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
export function initDB(config: AppConfig): void {
|
||||
const dbPath = config.paths.db;
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
|
||||
db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
endpoint TEXT PRIMARY KEY,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
last_used TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
ip TEXT NOT NULL,
|
||||
attempted_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_ip ON login_attempts(ip);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_stats (
|
||||
session_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
event_data TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_stats_session ON session_stats(session_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_reviews (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_cli_session_id TEXT NOT NULL,
|
||||
child_cli_session_id TEXT NOT NULL,
|
||||
child_adapter TEXT NOT NULL,
|
||||
parent_adapter TEXT NOT NULL DEFAULT 'claude',
|
||||
anchor_message_id TEXT,
|
||||
review_prompt TEXT,
|
||||
review_title TEXT,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
started_at TEXT DEFAULT (datetime('now')),
|
||||
ended_at TEXT DEFAULT NULL,
|
||||
end_anchor_message_id TEXT DEFAULT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_parent ON session_reviews(parent_cli_session_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saved_instructions (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
instruction TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: add parent_adapter column to session_reviews if missing
|
||||
const reviewInfo = db.prepare("PRAGMA table_info('session_reviews')").all() as { name: string }[];
|
||||
if (!reviewInfo.some(c => c.name === 'parent_adapter')) {
|
||||
db.exec("ALTER TABLE session_reviews ADD COLUMN parent_adapter TEXT NOT NULL DEFAULT 'claude'");
|
||||
}
|
||||
|
||||
// Migration: add end_anchor_message_id column to session_reviews if missing
|
||||
if (!reviewInfo.some(c => c.name === 'end_anchor_message_id')) {
|
||||
db.exec("ALTER TABLE session_reviews ADD COLUMN end_anchor_message_id TEXT DEFAULT NULL");
|
||||
}
|
||||
|
||||
// Drop legacy sessions table (no longer used — adapters use in-memory Maps)
|
||||
db.exec('DROP TABLE IF EXISTS sessions');
|
||||
|
||||
console.log('[db] SQLite database initialized at', dbPath);
|
||||
}
|
||||
|
||||
export function closeDB(): void {
|
||||
_stmts = null;
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
console.log('[db] Database closed');
|
||||
}
|
||||
}
|
||||
|
||||
function getDB(): BetterSqlite3.Database {
|
||||
if (!db) throw new Error('Database not initialized — call initDB() first');
|
||||
return db;
|
||||
}
|
||||
|
||||
// --- Cached Prepared Statements ---
|
||||
|
||||
interface PreparedStatements {
|
||||
pushSubsSave: BetterSqlite3.Statement;
|
||||
pushSubsRemove: BetterSqlite3.Statement;
|
||||
pushSubsGetAll: BetterSqlite3.Statement;
|
||||
pushSubsMarkUsed: BetterSqlite3.Statement;
|
||||
rateLimitRecord: BetterSqlite3.Statement;
|
||||
rateLimitCountRecent: BetterSqlite3.Statement;
|
||||
rateLimitCleanup: BetterSqlite3.Statement;
|
||||
preferencesGet: BetterSqlite3.Statement;
|
||||
preferencesSet: BetterSqlite3.Statement;
|
||||
reviewCreate: BetterSqlite3.Statement;
|
||||
reviewGetById: BetterSqlite3.Statement;
|
||||
reviewGetActiveForParent: BetterSqlite3.Statement;
|
||||
reviewGetAllForParent: BetterSqlite3.Statement;
|
||||
reviewGetAllChildIds: BetterSqlite3.Statement;
|
||||
reviewEnd: BetterSqlite3.Statement;
|
||||
reviewUpdateChildCliId: BetterSqlite3.Statement;
|
||||
instructionCreate: BetterSqlite3.Statement;
|
||||
instructionGetAll: BetterSqlite3.Statement;
|
||||
instructionDelete: BetterSqlite3.Statement;
|
||||
}
|
||||
|
||||
let _stmts: PreparedStatements | null = null;
|
||||
|
||||
function stmts(): PreparedStatements {
|
||||
if (!_stmts) {
|
||||
const d = getDB();
|
||||
_stmts = {
|
||||
// push_subscriptions
|
||||
pushSubsSave: d.prepare(`
|
||||
INSERT INTO push_subscriptions (endpoint, p256dh, auth)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth
|
||||
`),
|
||||
pushSubsRemove: d.prepare(
|
||||
`DELETE FROM push_subscriptions WHERE endpoint = ?`
|
||||
),
|
||||
pushSubsGetAll: d.prepare(
|
||||
`SELECT * FROM push_subscriptions`
|
||||
),
|
||||
pushSubsMarkUsed: d.prepare(
|
||||
`UPDATE push_subscriptions SET last_used = datetime('now') WHERE endpoint = ?`
|
||||
),
|
||||
// rate_limit
|
||||
rateLimitRecord: d.prepare(
|
||||
`INSERT INTO login_attempts (ip) VALUES (?)`
|
||||
),
|
||||
rateLimitCountRecent: d.prepare(
|
||||
`SELECT COUNT(*) AS cnt FROM login_attempts
|
||||
WHERE ip = ? AND attempted_at > datetime('now', '-' || ? || ' seconds')`
|
||||
),
|
||||
rateLimitCleanup: d.prepare(
|
||||
`DELETE FROM login_attempts WHERE attempted_at < datetime('now', '-1 hour')`
|
||||
),
|
||||
// preferences
|
||||
preferencesGet: d.prepare(
|
||||
`SELECT value FROM user_preferences WHERE key = ?`
|
||||
),
|
||||
preferencesSet: d.prepare(`
|
||||
INSERT INTO user_preferences (key, value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = datetime('now')
|
||||
`),
|
||||
// session_reviews
|
||||
reviewCreate: d.prepare(
|
||||
`INSERT INTO session_reviews (id, parent_cli_session_id, child_cli_session_id, child_adapter, parent_adapter, anchor_message_id, review_prompt, review_title)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
),
|
||||
reviewGetById: d.prepare(
|
||||
`SELECT * FROM session_reviews WHERE id = ?`
|
||||
),
|
||||
reviewGetActiveForParent: d.prepare(
|
||||
`SELECT * FROM session_reviews WHERE parent_cli_session_id = ? AND ended_at IS NULL`
|
||||
),
|
||||
reviewGetAllForParent: d.prepare(
|
||||
`SELECT * FROM session_reviews WHERE parent_cli_session_id = ? ORDER BY started_at`
|
||||
),
|
||||
reviewGetAllChildIds: d.prepare(
|
||||
`SELECT DISTINCT child_cli_session_id FROM session_reviews`
|
||||
),
|
||||
reviewEnd: d.prepare(
|
||||
`UPDATE session_reviews SET ended_at = datetime('now'), message_count = ?, end_anchor_message_id = ? WHERE id = ?`
|
||||
),
|
||||
reviewUpdateChildCliId: d.prepare(
|
||||
`UPDATE session_reviews SET child_cli_session_id = ? WHERE child_cli_session_id = ?`
|
||||
),
|
||||
// saved_instructions
|
||||
instructionCreate: d.prepare(
|
||||
`INSERT INTO saved_instructions (id, label, instruction) VALUES (?, ?, ?)`
|
||||
),
|
||||
instructionGetAll: d.prepare(
|
||||
`SELECT * FROM saved_instructions ORDER BY created_at ASC`
|
||||
),
|
||||
instructionDelete: d.prepare(
|
||||
`DELETE FROM saved_instructions WHERE id = ?`
|
||||
),
|
||||
};
|
||||
}
|
||||
return _stmts;
|
||||
}
|
||||
|
||||
// --- Session Review Types ---
|
||||
|
||||
export interface SessionReviewRow {
|
||||
id: string;
|
||||
parent_cli_session_id: string;
|
||||
child_cli_session_id: string;
|
||||
child_adapter: string;
|
||||
parent_adapter: string;
|
||||
anchor_message_id: string | null;
|
||||
review_prompt: string | null;
|
||||
review_title: string | null;
|
||||
message_count: number;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
end_anchor_message_id: string | null;
|
||||
}
|
||||
|
||||
// --- Push Subscription Operations ---
|
||||
|
||||
export interface PushSubRow {
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
created_at: string;
|
||||
last_used: string | null;
|
||||
}
|
||||
|
||||
export const pushSubs = {
|
||||
save(endpoint: string, p256dh: string, auth: string): void {
|
||||
stmts().pushSubsSave.run(endpoint, p256dh, auth);
|
||||
},
|
||||
|
||||
remove(endpoint: string): void {
|
||||
stmts().pushSubsRemove.run(endpoint);
|
||||
},
|
||||
|
||||
getAll(): PushSubRow[] {
|
||||
return stmts().pushSubsGetAll.all() as PushSubRow[];
|
||||
},
|
||||
|
||||
markUsed(endpoint: string): void {
|
||||
stmts().pushSubsMarkUsed.run(endpoint);
|
||||
},
|
||||
};
|
||||
|
||||
// --- Rate Limit Operations ---
|
||||
|
||||
export const rateLimit = {
|
||||
record(ip: string): void {
|
||||
stmts().rateLimitRecord.run(ip);
|
||||
},
|
||||
|
||||
countRecent(ip: string, windowSeconds: number = 60): number {
|
||||
const row = stmts().rateLimitCountRecent.get(ip, windowSeconds) as { cnt: number };
|
||||
return row.cnt;
|
||||
},
|
||||
|
||||
cleanup(): void {
|
||||
stmts().rateLimitCleanup.run();
|
||||
},
|
||||
};
|
||||
|
||||
// --- User Preferences Operations ---
|
||||
|
||||
export const preferences = {
|
||||
get(key: string): string | undefined {
|
||||
const row = stmts().preferencesGet.get(key) as { value: string } | undefined;
|
||||
return row?.value;
|
||||
},
|
||||
|
||||
set(key: string, value: string): void {
|
||||
stmts().preferencesSet.run(key, value);
|
||||
},
|
||||
};
|
||||
|
||||
// --- Session Review Operations ---
|
||||
|
||||
let _childIdCache: Set<string> | null = null;
|
||||
|
||||
export const sessionReviews = {
|
||||
create(
|
||||
id: string,
|
||||
parentCliId: string,
|
||||
childCliId: string,
|
||||
childAdapter: string,
|
||||
parentAdapter: string,
|
||||
anchorMsgId?: string,
|
||||
prompt?: string,
|
||||
title?: string
|
||||
): void {
|
||||
stmts().reviewCreate.run(
|
||||
id,
|
||||
parentCliId,
|
||||
childCliId,
|
||||
childAdapter,
|
||||
parentAdapter,
|
||||
anchorMsgId ?? null,
|
||||
prompt ?? null,
|
||||
title ?? null
|
||||
);
|
||||
_childIdCache = null; // invalidate cache
|
||||
},
|
||||
|
||||
getById(reviewId: string): SessionReviewRow | undefined {
|
||||
return stmts().reviewGetById.get(reviewId) as SessionReviewRow | undefined;
|
||||
},
|
||||
|
||||
getActiveForParent(parentCliSessionId: string): SessionReviewRow[] {
|
||||
return stmts().reviewGetActiveForParent.all(parentCliSessionId) as SessionReviewRow[];
|
||||
},
|
||||
|
||||
getAllForParent(parentCliSessionId: string): SessionReviewRow[] {
|
||||
return stmts().reviewGetAllForParent.all(parentCliSessionId) as SessionReviewRow[];
|
||||
},
|
||||
|
||||
getAllChildIds(): Set<string> {
|
||||
if (_childIdCache) return _childIdCache;
|
||||
const rows = stmts().reviewGetAllChildIds.all() as { child_cli_session_id: string }[];
|
||||
_childIdCache = new Set(rows.map(r => r.child_cli_session_id));
|
||||
return _childIdCache;
|
||||
},
|
||||
|
||||
endReview(reviewId: string, messageCount: number = 0, endAnchorMessageId?: string): void {
|
||||
stmts().reviewEnd.run(messageCount, endAnchorMessageId || null, reviewId);
|
||||
_childIdCache = null; // invalidate cache
|
||||
},
|
||||
|
||||
updateChildCliId(currentId: string, newCliId: string): void {
|
||||
stmts().reviewUpdateChildCliId.run(newCliId, currentId);
|
||||
_childIdCache = null; // invalidate cache
|
||||
},
|
||||
};
|
||||
|
||||
// --- Saved Instructions Operations ---
|
||||
|
||||
export const savedInstructions = {
|
||||
create(id: string, label: string, instruction: string): void {
|
||||
stmts().instructionCreate.run(id, label, instruction);
|
||||
},
|
||||
getAll(): { id: string; label: string; instruction: string; created_at: string }[] {
|
||||
return stmts().instructionGetAll.all() as any[];
|
||||
},
|
||||
delete(id: string): void {
|
||||
stmts().instructionDelete.run(id);
|
||||
},
|
||||
};
|
||||
+481
@@ -0,0 +1,481 @@
|
||||
import express from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { createServer as createHttpsServer } from 'https';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join, resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { readFileSync } from 'fs';
|
||||
import {
|
||||
initAuth,
|
||||
login,
|
||||
authMiddleware,
|
||||
} from './auth.js';
|
||||
import './adapters/init.js';
|
||||
import { initAll, listAvailable, get as getAdapter, getAll as getAllAdapters, cleanupAll, DEFAULT_ADAPTER } from './adapters/registry.js';
|
||||
import { initPush, getVapidPublicKey, saveSubscription, removeSubscription, getPendingSessions } from './push.js';
|
||||
import {
|
||||
setupSessionManager,
|
||||
handleIncomingMessage,
|
||||
getClientCount,
|
||||
broadcastReviewStarted,
|
||||
broadcastReviewEnded,
|
||||
registerSessionAdapter,
|
||||
} from './session-manager.js';
|
||||
import { WebSocketTransport } from './transport/websocket-transport.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import type { AppConfig } from './config.js';
|
||||
import { initDB, closeDB, sessionReviews, savedInstructions } from './db.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
import multer from 'multer';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { mkdirSync, writeFileSync, unlinkSync } from 'fs';
|
||||
import type { Server as HttpServer } from 'http';
|
||||
import type { Server as HttpsServer } from 'https';
|
||||
|
||||
// --- Start ---
|
||||
|
||||
async function start(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
initDB(config);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// Image upload config
|
||||
const uploadDir = config.paths.uploads;
|
||||
mkdirSync(uploadDir, { recursive: true });
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: uploadDir,
|
||||
filename: (_req: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
|
||||
const ext = file.originalname.split('.').pop() || 'png';
|
||||
cb(null, `${randomUUID()}.${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||
fileFilter: (_req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
|
||||
if (file.mimetype.startsWith('image/')) cb(null, true);
|
||||
else cb(new Error('Only images allowed'));
|
||||
},
|
||||
});
|
||||
|
||||
// Serve built frontend in production
|
||||
const distPath: string = join(__dirname, '..', 'dist');
|
||||
app.use(express.static(distPath));
|
||||
|
||||
// --- REST Routes ---
|
||||
|
||||
app.get('/health', (_req: Request, res: Response) => {
|
||||
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
||||
res.json({ status: 'ok', branch: config.gitBranch, version: pkg.version });
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (req: Request, res: Response) => {
|
||||
const { password } = req.body as { password?: string };
|
||||
if (!password) {
|
||||
return res.status(400).json({ error: 'Password required' });
|
||||
}
|
||||
const ip: string = req.ip || (req as any).connection?.remoteAddress || 'unknown';
|
||||
const result = await login(password, ip);
|
||||
if ('error' in result) {
|
||||
return res.status((result as { error: string; status: number }).status).json({ error: result.error });
|
||||
}
|
||||
res.json({ token: (result as { token: string }).token });
|
||||
});
|
||||
|
||||
app.get('/api/sessions', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { dir, limit } = req.query as { dir?: string; limit?: string };
|
||||
const parsedLimit = limit ? parseInt(limit) : 0;
|
||||
const adapters = getAllAdapters();
|
||||
const results = await Promise.all(
|
||||
[...adapters.entries()].map(([name, adapter]) =>
|
||||
adapter.getSessions(dir, parsedLimit || undefined)
|
||||
.then(sessions => sessions.map(s => ({ ...s, adapter: name })))
|
||||
.catch(err => { console.warn(`[sessions] Failed to get sessions from ${name}:`, (err as Error).message); return [] as any[]; })
|
||||
)
|
||||
);
|
||||
const allSessions = results.flat();
|
||||
allSessions.sort((a, b) => {
|
||||
const aTime = typeof a.lastModified === 'number' ? a.lastModified : new Date(a.lastModified || 0).getTime();
|
||||
const bTime = typeof b.lastModified === 'number' ? b.lastModified : new Date(b.lastModified || 0).getTime();
|
||||
return bTime - aTime;
|
||||
});
|
||||
if (parsedLimit > 0) allSessions.splice(parsedLimit);
|
||||
const childIds = sessionReviews.getAllChildIds();
|
||||
const filtered = allSessions.filter((s: any) => !childIds.has(s.sessionId));
|
||||
res.json(filtered);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/upload', authMiddleware, upload.single('image'), (req: Request, res: Response) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
|
||||
res.json({ path: req.file.path, filename: req.file.filename });
|
||||
});
|
||||
|
||||
app.get('/api/sessions/:id/messages', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { adapter: adapterName, dir } = req.query as { adapter?: string; dir?: string };
|
||||
const adapter = getAdapter(adapterName || DEFAULT_ADAPTER);
|
||||
const messages = await adapter!.getMessages(req.params.id as string, dir);
|
||||
res.json(messages);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/browse', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { path: queryPath, adapter: adapterName } = req.query as { path?: string; adapter?: string };
|
||||
const requestedPath = queryPath ? resolve(queryPath) : undefined;
|
||||
const home = homedir();
|
||||
if (requestedPath && !requestedPath.startsWith(home)) {
|
||||
return res.status(403).json({ error: 'Browsing restricted to home directory' });
|
||||
}
|
||||
const adapter = getAdapter(adapterName || DEFAULT_ADAPTER);
|
||||
const dirs = await adapter!.listDirectory(requestedPath);
|
||||
res.json(dirs);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/active-sessions', authMiddleware, (req: Request, res: Response) => {
|
||||
try {
|
||||
const { adapter: adapterName } = req.query as { adapter?: string };
|
||||
|
||||
let allActiveSessions: any[] = [];
|
||||
|
||||
if (adapterName) {
|
||||
const adapter = getAdapter(adapterName);
|
||||
if (adapter?.getActiveSessions) {
|
||||
allActiveSessions = adapter.getActiveSessions();
|
||||
}
|
||||
} else {
|
||||
// No adapter specified — aggregate all adapters
|
||||
const adapters = getAllAdapters();
|
||||
for (const [, adapter] of adapters) {
|
||||
if (adapter.getActiveSessions) {
|
||||
allActiveSessions.push(...adapter.getActiveSessions());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const s of allActiveSessions) {
|
||||
const count = getClientCount(s.sessionId);
|
||||
const totalCount = count + (s.hasDesktop ? 1 : 0);
|
||||
s.hasClients = totalCount > 0;
|
||||
(s as any).clientCount = totalCount;
|
||||
}
|
||||
const childIds = sessionReviews.getAllChildIds();
|
||||
const filteredActive = allActiveSessions.filter((s: any) => !childIds.has(s.sessionId));
|
||||
res.json(filteredActive);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Adapter Discovery Endpoints ---
|
||||
|
||||
app.get('/api/adapters', authMiddleware, (_req: Request, res: Response) => {
|
||||
res.json(listAvailable());
|
||||
});
|
||||
|
||||
app.get('/api/adapter/:name/config', authMiddleware, (req: Request, res: Response) => {
|
||||
const adapter = getAdapter(req.params.name as string);
|
||||
if (!adapter) return res.status(404).json({ error: 'Adapter not found' });
|
||||
res.json({
|
||||
models: adapter.getModels(),
|
||||
permissionModes: adapter.getPermissionModes(),
|
||||
effortLevels: adapter.getEffortLevels(),
|
||||
effortLabel: adapter.getEffortLabel(),
|
||||
capabilities: adapter.getCapabilities(),
|
||||
});
|
||||
});
|
||||
|
||||
// --- Session Management ---
|
||||
|
||||
app.delete('/api/active-sessions/:id', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { adapter: adapterName } = req.query as { adapter?: string };
|
||||
const adapter = getAdapter(adapterName || DEFAULT_ADAPTER);
|
||||
await adapter!.destroySession(req.params.id as string);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/sessions/start', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { adapter: adapterName, cwd, model, permissionMode } = req.body;
|
||||
if (!cwd) return res.status(400).json({ error: 'cwd required' });
|
||||
|
||||
const adapter = getAdapter(adapterName || DEFAULT_ADAPTER);
|
||||
if (!adapter) return res.status(400).json({ error: `Unknown adapter: ${adapterName}` });
|
||||
|
||||
const handle = await adapter.startSession(cwd, { model, permissionMode });
|
||||
registerSessionAdapter(handle.sessionId, adapterName || DEFAULT_ADAPTER);
|
||||
|
||||
res.json({ sessionId: handle.sessionId });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/sessions/resume', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId, adapter: adapterName, cwd } = req.body;
|
||||
if (!sessionId) return res.status(400).json({ error: 'sessionId required' });
|
||||
|
||||
const resolvedAdapter = adapterName || DEFAULT_ADAPTER;
|
||||
const adapter = getAdapter(resolvedAdapter);
|
||||
if (!adapter) return res.status(400).json({ error: `Unknown adapter: ${resolvedAdapter}` });
|
||||
|
||||
const handle = await adapter.resumeSession(sessionId, cwd || process.cwd());
|
||||
registerSessionAdapter(handle.sessionId, resolvedAdapter);
|
||||
|
||||
res.json({ sessionId: handle.sessionId });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Review API ---
|
||||
|
||||
// Register a review after the child session is already created via QUERY
|
||||
app.post('/api/reviews/register', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { parentCliSessionId, childSessionId, targetAdapter, anchorMessageId, prompt, title } = req.body;
|
||||
if (!parentCliSessionId || !childSessionId) {
|
||||
return res.status(400).json({ error: 'parentCliSessionId and childSessionId required' });
|
||||
}
|
||||
|
||||
// Find which adapter owns the parent session
|
||||
let parentAdapterName = DEFAULT_ADAPTER;
|
||||
for (const [name, a] of getAllAdapters()) {
|
||||
if (a.getSession(parentCliSessionId)) { parentAdapterName = name; break; }
|
||||
}
|
||||
|
||||
const reviewId = randomUUID();
|
||||
sessionReviews.create(reviewId, parentCliSessionId, childSessionId, targetAdapter, parentAdapterName, anchorMessageId, prompt, title);
|
||||
|
||||
// Ensure adapter mapping exists for the child session
|
||||
registerSessionAdapter(childSessionId, targetAdapter);
|
||||
|
||||
broadcastReviewStarted(parentCliSessionId, {
|
||||
reviewId,
|
||||
childSessionId,
|
||||
childCliSessionId: childSessionId,
|
||||
childAdapter: targetAdapter,
|
||||
anchorMessageId,
|
||||
reviewTitle: title,
|
||||
});
|
||||
|
||||
res.json({ reviewId });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/reviews/:id', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const review = sessionReviews.getById(req.params.id);
|
||||
if (!review) return res.status(404).json({ error: 'Review not found' });
|
||||
|
||||
const { endAnchorMessageId } = req.body || {};
|
||||
sessionReviews.endReview(review.id, 0, endAnchorMessageId);
|
||||
|
||||
// Broadcast to parent WS clients
|
||||
broadcastReviewEnded(review.parent_cli_session_id, review.id);
|
||||
|
||||
// Try to destroy child tmux session
|
||||
const childAdapter = getAdapter(review.child_adapter);
|
||||
if (childAdapter) {
|
||||
try {
|
||||
await childAdapter.destroySession(review.child_cli_session_id);
|
||||
} catch (err) {
|
||||
console.error('[review] Failed to destroy child session:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/reviews/:id/send-back', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const review = sessionReviews.getById(req.params.id);
|
||||
if (!review) return res.status(404).json({ error: 'Review not found' });
|
||||
|
||||
const { message } = req.body;
|
||||
if (!message) return res.status(400).json({ error: 'message required' });
|
||||
|
||||
// Find parent session from adapter's in-memory Map
|
||||
const parentAdapter = getAdapter(review.parent_adapter || DEFAULT_ADAPTER);
|
||||
if (!parentAdapter) return res.status(400).json({ error: 'Parent adapter not found' });
|
||||
|
||||
const parentSessionId = review.parent_cli_session_id;
|
||||
if (!parentAdapter.getSession(parentSessionId)) {
|
||||
return res.status(404).json({ error: 'Parent session not found' });
|
||||
}
|
||||
|
||||
// Check if parent is busy
|
||||
if (parentAdapter.isProcessing(parentSessionId)) {
|
||||
return res.status(409).json({ error: 'Parent session is busy. Wait for the current turn to complete.' });
|
||||
}
|
||||
|
||||
// Format and send
|
||||
const formatted = `[Review feedback from ${review.child_adapter}]:\n${message}`;
|
||||
await parentAdapter.sendMessage(parentSessionId, formatted);
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/reviews', authMiddleware, (req: Request, res: Response) => {
|
||||
try {
|
||||
const { parentCliSessionId } = req.query as { parentCliSessionId?: string };
|
||||
if (!parentCliSessionId) return res.status(400).json({ error: 'parentCliSessionId required' });
|
||||
|
||||
const reviews = sessionReviews.getAllForParent(parentCliSessionId);
|
||||
res.json(reviews);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Saved Instructions API ---
|
||||
|
||||
app.get('/api/instructions', authMiddleware, (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json(savedInstructions.getAll());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/instructions', authMiddleware, (req: Request, res: Response) => {
|
||||
try {
|
||||
const { label, instruction } = req.body;
|
||||
if (!label || !instruction) return res.status(400).json({ error: 'label and instruction required' });
|
||||
const id = randomUUID();
|
||||
savedInstructions.create(id, label, instruction);
|
||||
res.json({ id, label, instruction });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/instructions/:id', authMiddleware, (req: Request, res: Response) => {
|
||||
try {
|
||||
savedInstructions.delete(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Push Notification API ---
|
||||
app.get('/api/push/vapid-public-key', authMiddleware, (_req: Request, res: Response) => {
|
||||
res.json({ publicKey: getVapidPublicKey() });
|
||||
});
|
||||
|
||||
app.post('/api/push/subscribe', authMiddleware, (req: Request, res: Response) => {
|
||||
const { subscription } = req.body as { subscription?: { endpoint?: string } };
|
||||
if (!subscription?.endpoint) return res.status(400).json({ error: 'Missing subscription' });
|
||||
saveSubscription(subscription as any);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/push/unsubscribe', authMiddleware, (req: Request, res: Response) => {
|
||||
const { endpoint } = req.body as { endpoint?: string };
|
||||
if (!endpoint) return res.status(400).json({ error: 'Missing endpoint' });
|
||||
removeSubscription(endpoint);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/push/pending', authMiddleware, (_req: Request, res: Response) => {
|
||||
res.json(getPendingSessions());
|
||||
});
|
||||
|
||||
// SPA fallback
|
||||
app.get('*path', (_req: Request, res: Response) => {
|
||||
res.sendFile(join(distPath, 'index.html'));
|
||||
});
|
||||
|
||||
// --- Server + WebSocket ---
|
||||
|
||||
let server: HttpServer | HttpsServer;
|
||||
if (config.https) {
|
||||
server = createHttpsServer({ cert: config.https.cert, key: config.https.key }, app);
|
||||
} else {
|
||||
server = createServer(app);
|
||||
}
|
||||
|
||||
// --- WebSocket Transport ---
|
||||
const wsTransport = new WebSocketTransport();
|
||||
wsTransport.setup(server);
|
||||
wsTransport.on('connection', (conn) => {
|
||||
conn.send({ type: 'client-id', clientId: conn.clientId });
|
||||
});
|
||||
wsTransport.on('message', async (conn, msg) => {
|
||||
try {
|
||||
await handleIncomingMessage(conn, msg);
|
||||
} catch (err) {
|
||||
conn.send({ type: 'error', error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize all adapters (registers hook routes, configures CLI hooks)
|
||||
initAll(app);
|
||||
|
||||
setupSessionManager();
|
||||
|
||||
// --- Initialize and Listen ---
|
||||
|
||||
await initAuth(config);
|
||||
initPush(config);
|
||||
writeFileSync(config.paths.pid, String(process.pid));
|
||||
const protocol = config.https ? 'https' : 'http';
|
||||
server.listen(config.port, '0.0.0.0', () => {
|
||||
console.log(`ClawTap running on ${protocol}://0.0.0.0:${config.port}${config.https ? ' (HTTPS)' : ''}`);
|
||||
});
|
||||
|
||||
// --- Graceful Shutdown ---
|
||||
|
||||
async function shutdown(signal: string): Promise<void> {
|
||||
console.log(`\n[shutdown] ${signal} received, cleaning up...`);
|
||||
await cleanupAll();
|
||||
wsTransport.destroy();
|
||||
closeDB();
|
||||
try { unlinkSync(config.paths.pid); } catch {}
|
||||
server.close(() => process.exit(0));
|
||||
setTimeout(() => process.exit(0), 3000); // Force exit after 3s
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('uncaughtException', (err: Error) => {
|
||||
console.error('[fatal] uncaught exception:', err);
|
||||
cleanupAll().catch((e: unknown) => console.error('[cleanup]', e)).finally(() => process.exit(1));
|
||||
});
|
||||
process.on('unhandledRejection', (err: unknown) => {
|
||||
console.error('[fatal] unhandled rejection:', err);
|
||||
cleanupAll().catch((e: unknown) => console.error('[cleanup]', e)).finally(() => process.exit(1));
|
||||
});
|
||||
}
|
||||
|
||||
start().catch((err: unknown) => {
|
||||
console.error('Failed to start:', err);
|
||||
cleanupAll().catch((e: unknown) => console.error('[cleanup]', e)).finally(() => process.exit(1));
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* PermissionManager — manages pending permission and question requests.
|
||||
*
|
||||
* Extracted from TmuxAdapter to allow reuse across adapters.
|
||||
* Handles timeouts, session-scoped indexing, and bulk operations.
|
||||
*/
|
||||
|
||||
export interface PendingPermission {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export interface PendingQuestion {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
originalInput: Record<string, unknown>;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export class PermissionManager {
|
||||
private pendingPermissions = new Map<string, PendingPermission>();
|
||||
private pendingQuestions = new Map<string, PendingQuestion>();
|
||||
/** sessionId -> Set<requestId> for fast session-scoped lookups */
|
||||
private sessionPendingIds = new Map<string, Set<string>>();
|
||||
private timeoutMs: number;
|
||||
|
||||
constructor(timeoutMs = 120_000) {
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
// === Permissions ===
|
||||
|
||||
addPermission(requestId: string, sessionId: string, data: { toolName: string; input: Record<string, unknown> }): void {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingPermissions.delete(requestId);
|
||||
this._removePendingId(sessionId, requestId);
|
||||
}, this.timeoutMs);
|
||||
|
||||
this.pendingPermissions.set(requestId, {
|
||||
sessionId,
|
||||
requestId,
|
||||
toolName: data.toolName,
|
||||
input: data.input,
|
||||
timer,
|
||||
});
|
||||
this._trackPendingId(sessionId, requestId);
|
||||
}
|
||||
|
||||
resolvePermission(requestId: string): PendingPermission | undefined {
|
||||
const pending = this.pendingPermissions.get(requestId);
|
||||
if (!pending) return undefined;
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingPermissions.delete(requestId);
|
||||
this._removePendingId(pending.sessionId, requestId);
|
||||
return pending;
|
||||
}
|
||||
|
||||
// === Questions ===
|
||||
|
||||
addQuestion(requestId: string, sessionId: string, data: { originalInput: Record<string, unknown> }): void {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingQuestions.delete(requestId);
|
||||
this._removePendingId(sessionId, requestId);
|
||||
}, this.timeoutMs);
|
||||
|
||||
this.pendingQuestions.set(requestId, {
|
||||
sessionId,
|
||||
requestId,
|
||||
originalInput: data.originalInput,
|
||||
timer,
|
||||
});
|
||||
this._trackPendingId(sessionId, requestId);
|
||||
}
|
||||
|
||||
resolveQuestion(requestId: string): PendingQuestion | undefined {
|
||||
const pending = this.pendingQuestions.get(requestId);
|
||||
if (!pending) return undefined;
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingQuestions.delete(requestId);
|
||||
this._removePendingId(pending.sessionId, requestId);
|
||||
return pending;
|
||||
}
|
||||
|
||||
// === Session-scoped Operations ===
|
||||
|
||||
/** Get all pending permissions for a session (for reconnect replay). */
|
||||
getPendingForSession(sessionId: string): PendingPermission[] {
|
||||
const ids = this.sessionPendingIds.get(sessionId);
|
||||
if (!ids) return [];
|
||||
const result: PendingPermission[] = [];
|
||||
for (const reqId of ids) {
|
||||
const perm = this.pendingPermissions.get(reqId);
|
||||
if (perm) result.push(perm);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Get all pending questions for a session (for reconnect replay). */
|
||||
getQuestionsForSession(sessionId: string): PendingQuestion[] {
|
||||
const ids = this.sessionPendingIds.get(sessionId);
|
||||
if (!ids) return [];
|
||||
const result: PendingQuestion[] = [];
|
||||
for (const reqId of ids) {
|
||||
const q = this.pendingQuestions.get(reqId);
|
||||
if (q) result.push(q);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Clear all pending requests for a session (e.g., when all clients disconnect or turn ends). */
|
||||
dismissAll(sessionId: string): void {
|
||||
const ids = this.sessionPendingIds.get(sessionId);
|
||||
if (!ids) return;
|
||||
for (const reqId of ids) {
|
||||
const perm = this.pendingPermissions.get(reqId);
|
||||
if (perm) { clearTimeout(perm.timer); this.pendingPermissions.delete(reqId); }
|
||||
const q = this.pendingQuestions.get(reqId);
|
||||
if (q) { clearTimeout(q.timer); this.pendingQuestions.delete(reqId); }
|
||||
}
|
||||
this.sessionPendingIds.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all pending permissions for a session as a given behavior.
|
||||
* Returns the list of resolved permission requestIds (caller handles the actual response).
|
||||
*/
|
||||
resolveAllAs(sessionId: string, _behavior: string): string[] {
|
||||
const ids = this.sessionPendingIds.get(sessionId);
|
||||
if (!ids) return [];
|
||||
const resolved: string[] = [];
|
||||
for (const reqId of ids) {
|
||||
const perm = this.pendingPermissions.get(reqId);
|
||||
if (perm) {
|
||||
clearTimeout(perm.timer);
|
||||
this.pendingPermissions.delete(reqId);
|
||||
resolved.push(reqId);
|
||||
}
|
||||
const q = this.pendingQuestions.get(reqId);
|
||||
if (q) { clearTimeout(q.timer); this.pendingQuestions.delete(reqId); }
|
||||
}
|
||||
this.sessionPendingIds.delete(sessionId);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// === Internal ===
|
||||
|
||||
private _trackPendingId(sessionId: string, requestId: string): void {
|
||||
let ids = this.sessionPendingIds.get(sessionId);
|
||||
if (!ids) { ids = new Set(); this.sessionPendingIds.set(sessionId, ids); }
|
||||
ids.add(requestId);
|
||||
}
|
||||
|
||||
private _removePendingId(sessionId: string, requestId: string): void {
|
||||
const ids = this.sessionPendingIds.get(sessionId);
|
||||
if (ids) { ids.delete(requestId); if (ids.size === 0) this.sessionPendingIds.delete(sessionId); }
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import webpush from 'web-push';
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import type { AppConfig } from './config.js';
|
||||
import { pushSubs as dbPushSubs, type PushSubRow } from './db.js';
|
||||
|
||||
interface PushSubscriptionEntry {
|
||||
endpoint: string;
|
||||
subscription: {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
};
|
||||
}
|
||||
|
||||
// In-memory cache (populated from SQLite on init)
|
||||
let subscriptions: PushSubscriptionEntry[] = [];
|
||||
let cachedVapidPublicKey: string | null = null;
|
||||
|
||||
// Pending session notification counts (in-memory, resets on restart)
|
||||
const pendingSessions = new Map<string, number>(); // sessionId -> count
|
||||
|
||||
export function initPush(config: AppConfig): void {
|
||||
mkdirSync(config.clawtapDir, { recursive: true });
|
||||
|
||||
const vapidPath = config.paths.vapidKeys;
|
||||
|
||||
// Load or generate VAPID keys
|
||||
let vapidKeys: { publicKey: string; privateKey: string };
|
||||
if (existsSync(vapidPath)) {
|
||||
vapidKeys = JSON.parse(readFileSync(vapidPath, 'utf-8'));
|
||||
} else {
|
||||
vapidKeys = webpush.generateVAPIDKeys();
|
||||
writeFileSync(vapidPath, JSON.stringify(vapidKeys, null, 2));
|
||||
console.log('[push] Generated new VAPID keys');
|
||||
}
|
||||
|
||||
const email = process.env.VAPID_EMAIL || 'noreply@clawtap.local';
|
||||
cachedVapidPublicKey = vapidKeys.publicKey;
|
||||
webpush.setVapidDetails(`mailto:${email}`, vapidKeys.publicKey, vapidKeys.privateKey);
|
||||
|
||||
// Load subscriptions from SQLite into in-memory cache
|
||||
const rows = dbPushSubs.getAll();
|
||||
subscriptions = rows.map(row => ({
|
||||
endpoint: row.endpoint,
|
||||
subscription: {
|
||||
endpoint: row.endpoint,
|
||||
keys: { p256dh: row.p256dh, auth: row.auth },
|
||||
},
|
||||
}));
|
||||
|
||||
console.log(`[push] Initialized with ${subscriptions.length} subscription(s)`);
|
||||
}
|
||||
|
||||
export function getVapidPublicKey(): string | null {
|
||||
return cachedVapidPublicKey;
|
||||
}
|
||||
|
||||
export function saveSubscription(subscription: PushSubscriptionEntry['subscription']): void {
|
||||
// Save to SQLite
|
||||
dbPushSubs.save(subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth);
|
||||
// Update in-memory cache
|
||||
subscriptions = subscriptions.filter(s => s.endpoint !== subscription.endpoint);
|
||||
subscriptions.push({ endpoint: subscription.endpoint, subscription });
|
||||
}
|
||||
|
||||
export function removeSubscription(endpoint: string): void {
|
||||
// Remove from SQLite
|
||||
dbPushSubs.remove(endpoint);
|
||||
// Update in-memory cache
|
||||
subscriptions = subscriptions.filter(s => s.endpoint !== endpoint);
|
||||
}
|
||||
|
||||
export function incrementPending(sessionId: string): number {
|
||||
const count = (pendingSessions.get(sessionId) || 0) + 1;
|
||||
pendingSessions.set(sessionId, count);
|
||||
return _totalPending();
|
||||
}
|
||||
|
||||
export function clearPending(sessionId: string): number {
|
||||
pendingSessions.delete(sessionId);
|
||||
return _totalPending();
|
||||
}
|
||||
|
||||
export function getPendingSessions(): Record<string, number> {
|
||||
const result: Record<string, number> = {};
|
||||
for (const [sid, count] of pendingSessions) {
|
||||
result[sid] = count;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function sendPush(payload: unknown): Promise<void> {
|
||||
if (subscriptions.length === 0) return;
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const expired: string[] = [];
|
||||
|
||||
await Promise.allSettled(
|
||||
subscriptions.map(async ({ endpoint, subscription }) => {
|
||||
try {
|
||||
await webpush.sendNotification(subscription, body);
|
||||
} catch (err) {
|
||||
const e = err as { statusCode?: number; message?: string };
|
||||
if (e.statusCode === 410 || e.statusCode === 404) {
|
||||
// Subscription expired — mark for removal
|
||||
expired.push(endpoint);
|
||||
} else {
|
||||
console.error(`[push] Failed to send to ${endpoint.slice(0, 50)}:`, e.message);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Clean up expired subscriptions
|
||||
if (expired.length > 0) {
|
||||
for (const ep of expired) {
|
||||
dbPushSubs.remove(ep);
|
||||
}
|
||||
subscriptions = subscriptions.filter(s => !expired.includes(s.endpoint));
|
||||
console.log(`[push] Removed ${expired.length} expired subscription(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
function _totalPending(): number {
|
||||
let total = 0;
|
||||
for (const count of pendingSessions.values()) total += count;
|
||||
return total;
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import { get as getAdapter, getAll as getAllAdapters, DEFAULT_ADAPTER } from './adapters/registry.js';
|
||||
import type { IAdapter } from './adapters/interface.js';
|
||||
import { WS, PLAN_OPTION } from './ws-types.js';
|
||||
import type { ClientMessage, QueryOptions, PermissionBehavior } from './types/messages.js';
|
||||
import { sendPush, incrementPending, clearPending, getPendingSessions } from './push.js';
|
||||
import { basename } from 'path';
|
||||
import type { ClientConnection } from './transport/client-connection.js';
|
||||
import { sessionReviews } from './db.js';
|
||||
|
||||
/** Push notification options */
|
||||
interface PushOptions {
|
||||
title: string;
|
||||
body: string;
|
||||
tagPrefix: string;
|
||||
}
|
||||
|
||||
/** Send a push notification for a session event — only if nobody is viewing this session. */
|
||||
function triggerPush(adapter: IAdapter, sessionId: string, { title, body, tagPrefix }: PushOptions): void {
|
||||
const clients = sessionClients.get(sessionId);
|
||||
if (clients && clients.size > 0) return;
|
||||
|
||||
// Skip push for child review sessions
|
||||
if (sessionReviews.getAllChildIds().has(sessionId)) return;
|
||||
|
||||
const session = adapter.getSession(sessionId) as { cwd?: string } | null;
|
||||
const projectName = basename(session?.cwd || '') || 'Unknown';
|
||||
const badge = incrementPending(sessionId);
|
||||
sendPush({
|
||||
title,
|
||||
body: `${body} in ${projectName}`,
|
||||
tag: `${tagPrefix}-${sessionId}`,
|
||||
data: { sessionId, badge },
|
||||
}).catch((err: Error) => console.error('[push]', err.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionManager — bridges adapter events to connected clients.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Client lifecycle: register, unregister, reconnect
|
||||
* - Event routing: adapter events -> client broadcasts
|
||||
* - Session routing: new/resume/reconnect
|
||||
*
|
||||
* Transport-agnostic: works with ClientConnection, never raw WebSocket.
|
||||
* Adapter-generic: no direct imports of any specific adapter.
|
||||
*/
|
||||
|
||||
const sessionClients = new Map<string, Set<ClientConnection>>(); // sessionId -> Set<conn>
|
||||
const sessionAdapterMap = new Map<string, string>(); // sessionId -> adapterName
|
||||
// Codex sessions rekey from temp key to real UUID. If rekey happens before the
|
||||
// WS client connects (race condition with fast direct-match), the client registers
|
||||
// under the old key. This alias map resolves old → new so late-connecting clients
|
||||
// find the correct session.
|
||||
const rekeyAliases = new Map<string, string>(); // oldKey -> newKey
|
||||
|
||||
export function setupSessionManager(): void {
|
||||
const adapters = getAllAdapters();
|
||||
for (const [name, adapter] of adapters) {
|
||||
// Bridge adapter events -> client broadcasts (identical for every adapter)
|
||||
adapter.on('streaming-text', (sessionId: string, text: string) => {
|
||||
broadcast(sessionId, { type: WS.TEXT_DELTA, text });
|
||||
});
|
||||
|
||||
adapter.on('thinking', (sessionId: string, thinking: { text: string; detail?: string }) => {
|
||||
broadcast(sessionId, { type: WS.THINKING, text: thinking.text, detail: thinking.detail });
|
||||
});
|
||||
|
||||
adapter.on('tool-start', (sessionId: string, data: { toolName: string; [key: string]: unknown }) => {
|
||||
console.log(`[mgr] tool-start: ${data.toolName} for ${sessionId}`);
|
||||
broadcast(sessionId, { type: WS.TOOL_START, ...data });
|
||||
});
|
||||
|
||||
adapter.on('tool-done', (sessionId: string, data: { toolName: string; [key: string]: unknown }) => {
|
||||
console.log(`[mgr] tool-done: ${data.toolName} for ${sessionId}`);
|
||||
broadcast(sessionId, { type: WS.TOOL_DONE, ...data });
|
||||
});
|
||||
|
||||
adapter.on('new-messages', (sessionId: string, messages: Array<{ role: string; [key: string]: unknown }>) => {
|
||||
console.log(`[mgr] new-messages: ${messages.length} msgs (roles: ${messages.map(m => m.role).join(',')}) for ${sessionId}`);
|
||||
broadcast(sessionId, { type: WS.MESSAGE_COMPLETE, messages });
|
||||
});
|
||||
|
||||
adapter.on('tool-updates', (sessionId: string, tools: Record<string, unknown>) => {
|
||||
broadcast(sessionId, { type: WS.TOOL_UPDATES, tools });
|
||||
});
|
||||
|
||||
adapter.on('session-idle', (sessionId: string) => {
|
||||
// Stop hook fired — do a final poll before broadcasting turn-complete
|
||||
adapter.flushMessages(sessionId);
|
||||
// Small delay to ensure the pollNow result is broadcast first
|
||||
setTimeout(() => {
|
||||
broadcast(sessionId, { type: WS.TURN_COMPLETE, sessionId });
|
||||
}, 100);
|
||||
triggerPush(adapter, sessionId, { title: 'Claude finished', body: 'Turn complete', tagPrefix: 'idle' });
|
||||
});
|
||||
|
||||
adapter.on('permission-request', (sessionId: string, data: { toolName?: string; [key: string]: unknown }) => {
|
||||
broadcast(sessionId, { type: WS.PERMISSION_REQUEST, ...data });
|
||||
triggerPush(adapter, sessionId, { title: 'Permission needed', body: data.toolName || 'tool', tagPrefix: 'perm' });
|
||||
});
|
||||
|
||||
adapter.on('ask-question', (sessionId: string, data: { toolName?: string; [key: string]: unknown }) => {
|
||||
broadcast(sessionId, { type: WS.PERMISSION_REQUEST, ...data });
|
||||
triggerPush(adapter, sessionId, { title: 'Question from Claude', body: 'Waiting for answer', tagPrefix: 'ask' });
|
||||
});
|
||||
|
||||
adapter.on('status-update', (sessionId: string, status: Record<string, unknown>) => {
|
||||
// Dedup is handled by the adapter — just broadcast
|
||||
broadcast(sessionId, { type: WS.STATUS_UPDATE, ...status });
|
||||
});
|
||||
|
||||
adapter.on('mode-changed', (sessionId: string, mode: string) => {
|
||||
console.log(`[mgr] mode-changed: ${mode} for ${sessionId}`);
|
||||
broadcast(sessionId, { type: WS.MODE_UPDATED, mode });
|
||||
});
|
||||
|
||||
adapter.on('session-ended', (sessionId: string) => {
|
||||
broadcast(sessionId, { type: WS.SESSION_ENDED });
|
||||
|
||||
// Cascade child reviews — BEFORE deleting client set so broadcasts reach clients
|
||||
const activeChildren = sessionReviews.getActiveForParent(sessionId);
|
||||
for (const child of activeChildren) {
|
||||
sessionReviews.endReview(child.id);
|
||||
broadcast(sessionId, { type: WS.REVIEW_ENDED, reviewId: child.id });
|
||||
const childAdapterObj = getAdapter(child.child_adapter);
|
||||
if (childAdapterObj) {
|
||||
childAdapterObj.destroySession(child.child_cli_session_id).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// THEN clean up maps
|
||||
sessionClients.delete(sessionId);
|
||||
sessionAdapterMap.delete(sessionId);
|
||||
// Clean rekey alias pointing to this session
|
||||
for (const [oldKey, newKey] of rekeyAliases) {
|
||||
if (newKey === sessionId) rekeyAliases.delete(oldKey);
|
||||
}
|
||||
});
|
||||
|
||||
adapter.on('session-error', (sessionId: string, data: { errorType?: string; errorDetails?: string; [key: string]: unknown }) => {
|
||||
broadcast(sessionId, { type: WS.SESSION_ERROR, ...data });
|
||||
triggerPush(adapter, sessionId, {
|
||||
title: 'Session Error',
|
||||
body: data.errorType === 'rate_limit' ? 'Rate limited' : (data.errorDetails || data.errorType || 'Unknown error'),
|
||||
tagPrefix: 'error',
|
||||
});
|
||||
});
|
||||
|
||||
adapter.on('compacting', (sessionId: string) => {
|
||||
broadcast(sessionId, { type: WS.COMPACTING });
|
||||
});
|
||||
|
||||
adapter.on('compact-done', (sessionId: string) => {
|
||||
broadcast(sessionId, { type: WS.COMPACT_DONE });
|
||||
});
|
||||
|
||||
adapter.on('processing-started', (sessionId: string) => {
|
||||
broadcast(sessionId, { type: WS.SESSION_STATE, streaming: true });
|
||||
});
|
||||
|
||||
// When Codex re-keys a session from temp key to real CLI UUID,
|
||||
// move clients and adapter mapping to the new key
|
||||
adapter.on('session-rekeyed', (oldKey: string, newKey: string) => {
|
||||
// Store alias so late-connecting clients can resolve the old key
|
||||
rekeyAliases.set(oldKey, newKey);
|
||||
// Move clients from old key to new key
|
||||
const clients = sessionClients.get(oldKey);
|
||||
if (clients) {
|
||||
sessionClients.delete(oldKey);
|
||||
sessionClients.set(newKey, clients);
|
||||
// Update each client's sessionId
|
||||
for (const conn of clients) {
|
||||
conn.sessionId = newKey;
|
||||
}
|
||||
}
|
||||
// Move adapter mapping
|
||||
const adapterName = sessionAdapterMap.get(oldKey);
|
||||
if (adapterName) {
|
||||
sessionAdapterMap.delete(oldKey);
|
||||
sessionAdapterMap.set(newKey, adapterName);
|
||||
}
|
||||
// Update any active reviews that reference the old key as child (FIX 3)
|
||||
sessionReviews.updateChildCliId(oldKey, newKey);
|
||||
// Send updated SESSION_CREATED so frontend knows the real ID
|
||||
const resolvedAdapter = getAdapter(adapterName || DEFAULT_ADAPTER);
|
||||
if (resolvedAdapter && clients) {
|
||||
for (const conn of clients) {
|
||||
send(conn, {
|
||||
type: WS.SESSION_CREATED,
|
||||
sessionId: newKey,
|
||||
permissionMode: (resolvedAdapter.getSession(newKey) as any)?.permissionMode || (resolvedAdapter.getSession(newKey) as any)?.approvalPolicy,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set client checker so adapter can decide whether to intercept hooks
|
||||
adapter.setClientChecker((sessionId: string) => {
|
||||
const clients = sessionClients.get(sessionId);
|
||||
return !!(clients && clients.size > 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// === Helper: resolve adapter for a session ===
|
||||
|
||||
function getAdapterForSession(conn: ClientConnection, sessionId?: string): { adapter: IAdapter | undefined; sid: string } {
|
||||
const sid = sessionId || conn.sessionId || '';
|
||||
const name = sessionAdapterMap.get(sid) || DEFAULT_ADAPTER;
|
||||
return { adapter: getAdapter(name), sid };
|
||||
}
|
||||
|
||||
function sendSessionCreated(conn: ClientConnection, adapter: IAdapter, sessionId: string): void {
|
||||
const sessionObj = adapter.getSession(sessionId) as {
|
||||
permissionMode?: string;
|
||||
approvalPolicy?: string;
|
||||
} | null;
|
||||
send(conn, {
|
||||
type: WS.SESSION_CREATED,
|
||||
sessionId,
|
||||
permissionMode: sessionObj?.permissionMode || sessionObj?.approvalPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
// === Centralized Message Router ===
|
||||
|
||||
export async function handleIncomingMessage(conn: ClientConnection, msg: ClientMessage): Promise<void> {
|
||||
switch (msg.type) {
|
||||
case WS.QUERY:
|
||||
return handleQuery(conn, msg.prompt as string, (msg.options as QueryOptions) || {});
|
||||
case WS.PERMISSION_RESPONSE:
|
||||
return handlePermissionResponse(conn, msg.requestId as string, {
|
||||
behavior: msg.behavior as string,
|
||||
alwaysAllow: msg.alwaysAllow as boolean | undefined,
|
||||
});
|
||||
case WS.ASK_RESPONSE:
|
||||
return handleAskResponse(conn, msg.requestId as string, msg.response as string);
|
||||
case WS.ABORT:
|
||||
return handleAbort(conn, msg.sessionId as string | undefined);
|
||||
case WS.RECONNECT:
|
||||
return handleReconnect(conn, msg.sessionId as string | undefined, msg.adapter as string | undefined);
|
||||
case WS.SET_PERMISSION_MODE:
|
||||
return handleSetPermissionMode(conn, msg.sessionId as string, msg.mode as string);
|
||||
case WS.SET_MODEL:
|
||||
return handleSetModel(conn, msg.sessionId as string, msg.model as string);
|
||||
case WS.PLAN_RESPONSE:
|
||||
return handlePlanResponse(conn, msg.sessionId as string, msg.optionIndex as number, msg.text as string | undefined);
|
||||
default:
|
||||
conn.send({ type: 'error', error: `Unknown message type: ${msg.type}` });
|
||||
}
|
||||
}
|
||||
|
||||
// === Message Handlers ===
|
||||
|
||||
export async function handleQuery(conn: ClientConnection, prompt: string, options: QueryOptions): Promise<void> {
|
||||
const { cwd, model, sessionId, permissionMode, images, adapter: adapterName } = options;
|
||||
const adapter = getAdapter(adapterName || DEFAULT_ADAPTER);
|
||||
if (!adapter) throw new Error(`Unknown adapter: ${adapterName}`);
|
||||
|
||||
let handle: { sessionId: string };
|
||||
if (sessionId) {
|
||||
handle = await adapter.resumeSession(sessionId, cwd as string, { permissionMode });
|
||||
} else {
|
||||
handle = await adapter.startSession(cwd || process.cwd(), { model, permissionMode });
|
||||
}
|
||||
|
||||
sessionAdapterMap.set(handle.sessionId, adapterName || DEFAULT_ADAPTER);
|
||||
registerClient(conn, handle.sessionId);
|
||||
sendSessionCreated(conn, adapter, handle.sessionId);
|
||||
|
||||
// Send the message (images sent as text description for now)
|
||||
let messageText = prompt;
|
||||
if (!sessionId && adapterName !== 'claude') {
|
||||
// New session — prepend marker for Codex UUID matching (Claude already knows its UUID)
|
||||
messageText = `[CLAWTAP_REF:${handle.sessionId}]\n${prompt}`;
|
||||
}
|
||||
if (images && images.length > 0) {
|
||||
messageText = messageText + '\n\n' + images.map((img: string) => `[Image: ${img}]`).join('\n');
|
||||
}
|
||||
await adapter.sendMessage(handle.sessionId, messageText, { clientId: conn.clientId });
|
||||
}
|
||||
|
||||
export function handlePermissionResponse(conn: ClientConnection, requestId: string, response: { behavior: PermissionBehavior; alwaysAllow?: boolean }): void {
|
||||
const { adapter, sid } = getAdapterForSession(conn);
|
||||
if (adapter) {
|
||||
adapter.respondPermission(requestId, response.behavior);
|
||||
broadcast(sid, { type: WS.PERMISSION_DISMISSED, requestId });
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAskResponse(conn: ClientConnection, requestId: string, answers: string): Promise<void> {
|
||||
const { adapter, sid } = getAdapterForSession(conn);
|
||||
if (adapter) {
|
||||
adapter.respondQuestion(requestId, answers);
|
||||
broadcast(sid, { type: WS.PERMISSION_DISMISSED, requestId });
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAbort(conn: ClientConnection, sessionId?: string): Promise<void> {
|
||||
const { adapter, sid } = getAdapterForSession(conn, sessionId);
|
||||
if (sid && adapter) await adapter.interrupt(sid);
|
||||
}
|
||||
|
||||
export async function handlePlanResponse(conn: ClientConnection, sessionId: string, optionIndex: number, text?: string): Promise<void> {
|
||||
const { adapter, sid } = getAdapterForSession(conn, sessionId);
|
||||
if (!sid || !adapter) return;
|
||||
await adapter.respondPlan(sid, optionIndex, text);
|
||||
// Broadcast synthetic user message so plan card transitions to read-only on ALL clients
|
||||
// Options: 0=bypass (YOLO), 1=manually approve, 2=text feedback
|
||||
const labels = ['Plan approved (YOLO).', 'Plan approved.'];
|
||||
const msg = optionIndex === PLAN_OPTION.TEXT_FEEDBACK ? (text || 'Rejected.') : (labels[optionIndex] || 'Plan approved.');
|
||||
broadcast(sid, { type: WS.MESSAGE_COMPLETE, messages: [{ role: 'user', content: msg }] });
|
||||
}
|
||||
|
||||
export async function handleReconnect(conn: ClientConnection, sessionId?: string, adapterHint?: string): Promise<void> {
|
||||
if (!sessionId) return;
|
||||
|
||||
// Resolve rekey alias (Codex temp key → real UUID)
|
||||
const resolvedId = rekeyAliases.get(sessionId) || sessionId;
|
||||
|
||||
const adapterName = sessionAdapterMap.get(resolvedId) || adapterHint || DEFAULT_ADAPTER;
|
||||
const adapter = getAdapter(adapterName);
|
||||
if (!adapter) return;
|
||||
|
||||
registerClient(conn, sessionId); // registerClient also resolves alias internally
|
||||
sessionAdapterMap.set(resolvedId, adapterName);
|
||||
|
||||
// Clear pending push notifications for this session and update badge (only if there were pending)
|
||||
if (getPendingSessions()[resolvedId]) {
|
||||
const remaining = clearPending(resolvedId);
|
||||
sendPush({ data: { badge: remaining } }).catch(() => {});
|
||||
}
|
||||
// Always send SESSION_CREATED on reconnect — includes permissionMode
|
||||
sendSessionCreated(conn, adapter, resolvedId);
|
||||
|
||||
// Send cached status (context %, model, cost) if available
|
||||
const lastStatus = adapter.getLastStatus(resolvedId);
|
||||
if (lastStatus) {
|
||||
send(conn, { type: WS.STATUS_UPDATE, ...lastStatus });
|
||||
}
|
||||
|
||||
// Advance watcher past current file position BEFORE loading history —
|
||||
// prevents watcher from emitting entries during the async getMessages() read
|
||||
// that would duplicate what HISTORY_LOAD delivers
|
||||
adapter.syncWatcherPosition(resolvedId);
|
||||
|
||||
// Send current messages from store (full history for reconnection)
|
||||
try {
|
||||
const { messages } = await adapter.getMessages(resolvedId);
|
||||
if (messages.length > 0) {
|
||||
send(conn, { type: WS.HISTORY_LOAD, messages });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Notify client if session is actively processing
|
||||
if (adapter.isProcessing(resolvedId)) {
|
||||
send(conn, { type: WS.SESSION_STATE, streaming: true });
|
||||
}
|
||||
|
||||
// Replay pending state (running tools, permission/question overlays)
|
||||
const pending = adapter.getReconnectState(resolvedId);
|
||||
if (pending.tools) {
|
||||
send(conn, { type: WS.TOOL_UPDATES, tools: pending.tools });
|
||||
}
|
||||
for (const req of pending.pendingRequests) {
|
||||
const { type: _type, ...rest } = req as Record<string, unknown>;
|
||||
send(conn, { type: WS.PERMISSION_REQUEST, ...rest });
|
||||
}
|
||||
|
||||
// Restore active child reviews
|
||||
try {
|
||||
const activeReviews = sessionReviews.getActiveForParent(resolvedId);
|
||||
for (const review of activeReviews) {
|
||||
const childAdapterObj = getAdapter(review.child_adapter);
|
||||
if (!childAdapterObj) continue;
|
||||
|
||||
// Check if child session still exists in adapter's in-memory Map.
|
||||
// If not (server restarted or windows killed), mark review as ended.
|
||||
if (!childAdapterObj.getSession(review.child_cli_session_id)) {
|
||||
sessionReviews.endReview(review.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
send(conn, {
|
||||
type: WS.REVIEW_STARTED,
|
||||
reviewId: review.id,
|
||||
childSessionId: review.child_cli_session_id,
|
||||
childCliSessionId: review.child_cli_session_id,
|
||||
childAdapter: review.child_adapter,
|
||||
anchorMessageId: review.anchor_message_id,
|
||||
reviewTitle: review.review_title,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[handleReconnect] Failed to restore child reviews:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSetModel(conn: ClientConnection, sessionId: string, model: string): Promise<void> {
|
||||
const { adapter, sid } = getAdapterForSession(conn, sessionId);
|
||||
if (adapter && sid) {
|
||||
await adapter.switchModel(sid, model);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSetPermissionMode(conn: ClientConnection, sessionId: string, mode: string): Promise<void> {
|
||||
const { adapter, sid } = getAdapterForSession(conn, sessionId);
|
||||
if (!sid || !adapter) return;
|
||||
|
||||
const success = await adapter.switchPermissionMode(sid, mode);
|
||||
if (success) {
|
||||
broadcast(sid, { type: WS.MODE_UPDATED, mode });
|
||||
// Only auto-resolve permissions for cycle-type adapters where we know the exact mode
|
||||
const capabilities = adapter.getCapabilities();
|
||||
if (capabilities.permissionModeType !== 'toggle') {
|
||||
if (mode === 'bypassPermissions') {
|
||||
adapter.resolveAllPendingAs(sid, 'allow');
|
||||
} else {
|
||||
adapter.releaseAllPending(sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Client Management ===
|
||||
|
||||
function registerClient(conn: ClientConnection, sessionId: string): void {
|
||||
// Resolve rekey alias: if sessionId was a temp key that's been re-keyed, use the new key
|
||||
const resolvedId = rekeyAliases.get(sessionId) || sessionId;
|
||||
if (resolvedId !== sessionId) {
|
||||
send(conn, { type: WS.SESSION_CREATED, sessionId: resolvedId });
|
||||
}
|
||||
|
||||
const existingSession = conn.sessionId;
|
||||
if (existingSession === resolvedId) {
|
||||
const set = sessionClients.get(resolvedId);
|
||||
if (set) set.add(conn);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from old session if switching
|
||||
if (existingSession) {
|
||||
const oldSet = sessionClients.get(existingSession);
|
||||
if (oldSet) oldSet.delete(conn);
|
||||
}
|
||||
|
||||
conn.sessionId = resolvedId;
|
||||
let clients = sessionClients.get(resolvedId);
|
||||
if (!clients) {
|
||||
clients = new Set();
|
||||
sessionClients.set(resolvedId, clients);
|
||||
}
|
||||
clients.add(conn);
|
||||
|
||||
// Set up disconnect handler (idempotent — ClientConnection fires 'close' once)
|
||||
conn.onDisconnect = (c: ClientConnection) => {
|
||||
const sid = c.sessionId;
|
||||
if (!sid) return;
|
||||
const set = sessionClients.get(sid);
|
||||
if (set) {
|
||||
set.delete(c);
|
||||
if (set.size === 0) {
|
||||
const adapterName = sessionAdapterMap.get(sid) || DEFAULT_ADAPTER;
|
||||
const adapter = getAdapter(adapterName);
|
||||
// Only release pending permissions if session is idle — if processing,
|
||||
// the client may be refreshing and will reconnect shortly to see the overlay
|
||||
if (adapter && !adapter.isProcessing(sid)) {
|
||||
adapter.releaseAllPending(sid);
|
||||
}
|
||||
console.log(`[session-mgr] All clients disconnected from ${sid}, session persists`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// === Broadcasting ===
|
||||
|
||||
function send(conn: ClientConnection, message: Record<string, unknown>): void {
|
||||
if (conn.shouldReceive(message as any)) conn.send(message as any);
|
||||
}
|
||||
|
||||
function broadcast(sessionId: string, message: Record<string, unknown>): void {
|
||||
const clients = sessionClients.get(sessionId);
|
||||
if (!clients || clients.size === 0) return;
|
||||
const json = JSON.stringify(message);
|
||||
for (const conn of clients) {
|
||||
if (conn.shouldReceive(message as any)) conn.sendRaw(json);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: childCliSessionId is redundant with childSessionId (they are always the same
|
||||
// CLI UUID now). Remove childCliSessionId from WS protocol and frontend state types.
|
||||
export function broadcastReviewStarted(parentSessionId: string, review: {
|
||||
reviewId: string;
|
||||
childSessionId: string;
|
||||
childCliSessionId: string;
|
||||
childAdapter: string;
|
||||
anchorMessageId?: string;
|
||||
reviewTitle?: string;
|
||||
}): void {
|
||||
broadcast(parentSessionId, { type: WS.REVIEW_STARTED, ...review });
|
||||
}
|
||||
|
||||
export function broadcastReviewEnded(parentSessionId: string, reviewId: string): void {
|
||||
broadcast(parentSessionId, { type: WS.REVIEW_ENDED, reviewId });
|
||||
}
|
||||
|
||||
export function getClientCount(sessionId: string): number {
|
||||
const clients = sessionClients.get(sessionId);
|
||||
return clients ? clients.size : 0;
|
||||
}
|
||||
|
||||
export function registerSessionAdapter(sessionId: string, adapterName: string): void {
|
||||
sessionAdapterMap.set(sessionId, adapterName);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import fs from 'fs';
|
||||
|
||||
/**
|
||||
* JSON Watcher for Gemini CLI session files.
|
||||
*
|
||||
* Gemini CLI stores sessions as single JSON files that are rewritten entirely
|
||||
* on each update (unlike Claude/Codex JSONL files that are append-only).
|
||||
* Byte-offset tracking does not work here — instead we track message count
|
||||
* and last message ID to detect and emit only new messages.
|
||||
*
|
||||
* Uses fs.watch() for instant change notification + fallback polling + debounce.
|
||||
*/
|
||||
|
||||
export interface GeminiSessionMessage {
|
||||
id: string;
|
||||
type: 'user' | 'gemini' | 'error' | 'info';
|
||||
content: Array<{ text: string }> | string;
|
||||
timestamp?: string;
|
||||
thoughts?: unknown[];
|
||||
tokens?: Record<string, unknown>;
|
||||
model?: string;
|
||||
toolCalls?: unknown[];
|
||||
}
|
||||
|
||||
export interface JsonWatcherStartOptions {
|
||||
skipExisting?: boolean;
|
||||
fallbackIntervalMs?: number;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
const SIZE_WARNING_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
export class JsonWatcher {
|
||||
filePath: string;
|
||||
private _lastMessageCount: number;
|
||||
private _lastMessageId: string | null;
|
||||
private _lastFileSize: number;
|
||||
private _onMessages: ((messages: GeminiSessionMessage[]) => void) | null;
|
||||
private _onError: ((err: Error) => void) | null;
|
||||
private _fsWatcher: fs.FSWatcher | null;
|
||||
private _fallbackInterval: ReturnType<typeof setInterval> | null;
|
||||
private _debounceTimer: ReturnType<typeof setTimeout> | null;
|
||||
private _debounceMs: number;
|
||||
private _polling: boolean;
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.filePath = filePath;
|
||||
this._lastMessageCount = 0;
|
||||
this._lastMessageId = null;
|
||||
this._lastFileSize = 0;
|
||||
this._onMessages = null;
|
||||
this._onError = null;
|
||||
this._fsWatcher = null;
|
||||
this._fallbackInterval = null;
|
||||
this._debounceTimer = null;
|
||||
this._debounceMs = 50;
|
||||
this._polling = false;
|
||||
}
|
||||
|
||||
start({ skipExisting = true, fallbackIntervalMs = 2000, debounceMs = 50 }: JsonWatcherStartOptions = {}): void {
|
||||
this._debounceMs = debounceMs;
|
||||
|
||||
if (skipExisting) {
|
||||
try {
|
||||
const stats = fs.statSync(this.filePath);
|
||||
this._lastFileSize = stats.size;
|
||||
// Read existing messages to set baseline counts without emitting them
|
||||
const raw = fs.readFileSync(this.filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { messages?: GeminiSessionMessage[] };
|
||||
const messages = parsed.messages ?? [];
|
||||
this._lastMessageCount = messages.length;
|
||||
this._lastMessageId = messages.length > 0 ? messages[messages.length - 1].id : null;
|
||||
} catch {
|
||||
// File may not exist yet — that's fine
|
||||
}
|
||||
}
|
||||
|
||||
// Primary: fs.watch() for instant change notification (~1-3ms latency)
|
||||
try {
|
||||
this._fsWatcher = fs.watch(this.filePath, () => this._schedulePoll());
|
||||
} catch {
|
||||
// fs.watch may fail on some systems — fallback polling handles it
|
||||
}
|
||||
|
||||
// Fallback: poll every N ms in case fs.watch misses events
|
||||
this._fallbackInterval = setInterval(() => this._poll(), fallbackIntervalMs);
|
||||
|
||||
// Immediate first poll (only meaningful when skipExisting = false)
|
||||
if (!skipExisting) {
|
||||
this._poll();
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this._fsWatcher) { this._fsWatcher.close(); this._fsWatcher = null; }
|
||||
if (this._fallbackInterval) { clearInterval(this._fallbackInterval); this._fallbackInterval = null; }
|
||||
if (this._debounceTimer) { clearTimeout(this._debounceTimer); this._debounceTimer = null; }
|
||||
}
|
||||
|
||||
onNewMessages(cb: (messages: GeminiSessionMessage[]) => void): void { this._onMessages = cb; }
|
||||
onError(cb: (err: Error) => void): void { this._onError = cb; }
|
||||
|
||||
/** Force an immediate poll (used by Stop hook to ensure final messages are read) */
|
||||
pollNow(): void { this._poll(); }
|
||||
|
||||
/** Mark current file position — subsequent polls only return messages after current state. */
|
||||
markCurrentPosition(): void {
|
||||
try {
|
||||
const stats = fs.statSync(this.filePath);
|
||||
this._lastFileSize = stats.size;
|
||||
const raw = fs.readFileSync(this.filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { messages?: GeminiSessionMessage[] };
|
||||
const messages = parsed.messages ?? [];
|
||||
this._lastMessageCount = messages.length;
|
||||
this._lastMessageId = messages.length > 0 ? messages[messages.length - 1].id : null;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private _schedulePoll(): void {
|
||||
// Debounce: coalesce rapid rewrites within debounceMs window
|
||||
if (this._debounceTimer) clearTimeout(this._debounceTimer);
|
||||
this._debounceTimer = setTimeout(() => {
|
||||
this._debounceTimer = null;
|
||||
this._poll();
|
||||
}, this._debounceMs);
|
||||
}
|
||||
|
||||
private _poll(): void {
|
||||
if (this._polling) return; // Prevent re-entrant polls
|
||||
this._polling = true;
|
||||
try {
|
||||
const stats = fs.statSync(this.filePath);
|
||||
|
||||
// File size guard: skip if unchanged (filters false-positive fs.watch events)
|
||||
if (stats.size === this._lastFileSize) {
|
||||
this._polling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (stats.size > SIZE_WARNING_BYTES) {
|
||||
console.warn(`[JsonWatcher] File exceeds 2MB (${stats.size} bytes): ${this.filePath}`);
|
||||
}
|
||||
|
||||
this._lastFileSize = stats.size;
|
||||
|
||||
const raw = fs.readFileSync(this.filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { messages?: GeminiSessionMessage[] };
|
||||
const messages = parsed.messages ?? [];
|
||||
|
||||
if (messages.length <= this._lastMessageCount) {
|
||||
// No new messages (or messages were deleted — reset baseline)
|
||||
if (messages.length < this._lastMessageCount) {
|
||||
this._lastMessageCount = messages.length;
|
||||
this._lastMessageId = messages.length > 0 ? messages[messages.length - 1].id : null;
|
||||
}
|
||||
this._polling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify continuity: check that the last known message still exists at its index
|
||||
if (this._lastMessageId !== null && this._lastMessageCount > 0) {
|
||||
const anchorMsg = messages[this._lastMessageCount - 1];
|
||||
if (!anchorMsg || anchorMsg.id !== this._lastMessageId) {
|
||||
// Message history was modified — reset baseline to current state without emitting
|
||||
this._lastMessageCount = messages.length;
|
||||
this._lastMessageId = messages.length > 0 ? messages[messages.length - 1].id : null;
|
||||
this._polling = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract new messages starting from last known count
|
||||
const newMessages = messages.slice(this._lastMessageCount);
|
||||
|
||||
// Advance position
|
||||
this._lastMessageCount = messages.length;
|
||||
this._lastMessageId = messages[messages.length - 1].id;
|
||||
|
||||
if (newMessages.length > 0 && this._onMessages) {
|
||||
this._onMessages(newMessages);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && this._onError) {
|
||||
this._onError(err as Error);
|
||||
}
|
||||
} finally {
|
||||
this._polling = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import fs from 'fs';
|
||||
|
||||
/**
|
||||
* Improved JSONL Watcher
|
||||
*
|
||||
* Uses fs.watch() for instant change notification + fallback polling.
|
||||
* Reads only new bytes from file using byte offset tracking.
|
||||
*/
|
||||
|
||||
export interface JsonlWatcherStartOptions {
|
||||
skipExisting?: boolean;
|
||||
fallbackIntervalMs?: number;
|
||||
}
|
||||
|
||||
export class JsonlWatcher {
|
||||
filePath: string;
|
||||
lastByteOffset: number;
|
||||
private _onEntries: ((entries: unknown[]) => void) | null;
|
||||
private _onError: ((err: Error) => void) | null;
|
||||
private _fsWatcher: fs.FSWatcher | null;
|
||||
private _fallbackInterval: ReturnType<typeof setInterval> | null;
|
||||
private _polling: boolean;
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.filePath = filePath;
|
||||
this.lastByteOffset = 0;
|
||||
this._onEntries = null;
|
||||
this._onError = null;
|
||||
this._fsWatcher = null;
|
||||
this._fallbackInterval = null;
|
||||
this._polling = false;
|
||||
}
|
||||
|
||||
start({ skipExisting = true, fallbackIntervalMs = 2000 }: JsonlWatcherStartOptions = {}): void {
|
||||
if (skipExisting) {
|
||||
try {
|
||||
this.lastByteOffset = fs.statSync(this.filePath).size;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Primary: fs.watch() for instant change notification (~1-3ms latency)
|
||||
try {
|
||||
this._fsWatcher = fs.watch(this.filePath, () => this._poll());
|
||||
} catch {
|
||||
// fs.watch may fail on some systems — fallback polling handles it
|
||||
}
|
||||
|
||||
// Fallback: poll every N ms in case fs.watch misses events
|
||||
this._fallbackInterval = setInterval(() => this._poll(), fallbackIntervalMs);
|
||||
|
||||
// Immediate first poll
|
||||
this._poll();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this._fsWatcher) { this._fsWatcher.close(); this._fsWatcher = null; }
|
||||
if (this._fallbackInterval) { clearInterval(this._fallbackInterval); this._fallbackInterval = null; }
|
||||
}
|
||||
|
||||
onNewEntries(cb: (entries: unknown[]) => void): void { this._onEntries = cb; }
|
||||
onError(cb: (err: Error) => void): void { this._onError = cb; }
|
||||
|
||||
/** Force an immediate poll (used by Stop hook to ensure final entries are read) */
|
||||
pollNow(): void { this._poll(); }
|
||||
|
||||
/** Mark current file position — subsequent polls only return content after this point. */
|
||||
markCurrentPosition(): void {
|
||||
try {
|
||||
this.lastByteOffset = fs.statSync(this.filePath).size;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private _poll(): void {
|
||||
if (this._polling) return; // Prevent re-entrant polls
|
||||
this._polling = true;
|
||||
try {
|
||||
const stats = fs.statSync(this.filePath);
|
||||
|
||||
// Detect truncation (/clear command)
|
||||
if (this.lastByteOffset > stats.size) {
|
||||
this.lastByteOffset = 0;
|
||||
}
|
||||
|
||||
if (stats.size <= this.lastByteOffset) {
|
||||
this._polling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const newSize = stats.size - this.lastByteOffset;
|
||||
const buffer = Buffer.alloc(newSize);
|
||||
const fd = fs.openSync(this.filePath, 'r');
|
||||
try {
|
||||
fs.readSync(fd, buffer, 0, newSize, this.lastByteOffset);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
|
||||
const text = buffer.toString('utf-8');
|
||||
const lines = text.split('\n');
|
||||
// Remove trailing empty string from split (artifact of text ending with \n)
|
||||
// Without this fix, bytesConsumed overshoots by 1, corrupting subsequent reads.
|
||||
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
||||
|
||||
const entries: unknown[] = [];
|
||||
let bytesConsumed = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const lineBytes = Buffer.byteLength(line + '\n', 'utf-8');
|
||||
if (!line.trim()) {
|
||||
bytesConsumed += lineBytes;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
entries.push(JSON.parse(line));
|
||||
bytesConsumed += lineBytes;
|
||||
} catch {
|
||||
// Partial JSON line — don't advance offset, retry next poll
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.lastByteOffset += bytesConsumed;
|
||||
|
||||
if (entries.length > 0 && this._onEntries) {
|
||||
this._onEntries(entries);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && this._onError) {
|
||||
this._onError(err as Error);
|
||||
}
|
||||
} finally {
|
||||
this._polling = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import crypto from 'crypto';
|
||||
import type { ServerMessage } from '../types/messages.js';
|
||||
|
||||
/**
|
||||
* ClientConnection — abstract base class for transport-agnostic client connections.
|
||||
*
|
||||
* Each connected client (WebSocket, SSE, etc.) is represented by a ClientConnection.
|
||||
* SessionManager works exclusively with this abstraction — never with raw WebSocket.
|
||||
*/
|
||||
export abstract class ClientConnection {
|
||||
readonly clientId: string = crypto.randomUUID();
|
||||
readonly transportName: string;
|
||||
sessionId: string | null = null;
|
||||
onDisconnect: ((conn: ClientConnection) => void) | null = null;
|
||||
|
||||
constructor(transportName: string) {
|
||||
this.transportName = transportName;
|
||||
}
|
||||
|
||||
/** Send a message to this client. Implementation handles serialization. */
|
||||
abstract send(message: ServerMessage): void;
|
||||
|
||||
/** Check if the connection is still alive. */
|
||||
abstract isAlive(): boolean;
|
||||
|
||||
/** Close the connection. */
|
||||
close(): void {}
|
||||
|
||||
/** Filter: should this client receive a given message? Default: yes. */
|
||||
shouldReceive(_message: ServerMessage): boolean { return true; }
|
||||
|
||||
/** Send a pre-serialized JSON string. Default fallback parses and calls send(). */
|
||||
sendRaw(json: string): void { this.send(JSON.parse(json)); }
|
||||
|
||||
/** Notify the disconnect handler (called by subclass when connection drops). */
|
||||
notifyDisconnect(): void { this.onDisconnect?.(this); }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type WebSocket from 'ws';
|
||||
import { ClientConnection } from './client-connection.js';
|
||||
import type { ServerMessage } from '../types/messages.js';
|
||||
|
||||
/**
|
||||
* WebSocketConnection — wraps a raw ws.WebSocket as a ClientConnection.
|
||||
*/
|
||||
export class WebSocketConnection extends ClientConnection {
|
||||
private ws: WebSocket;
|
||||
|
||||
constructor(ws: WebSocket) {
|
||||
super('websocket');
|
||||
this.ws = ws;
|
||||
ws.on('close', () => this.notifyDisconnect());
|
||||
}
|
||||
|
||||
send(message: ServerMessage): void {
|
||||
try {
|
||||
if (this.ws.readyState === 1) this.ws.send(JSON.stringify(message));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
sendRaw(json: string): void {
|
||||
try {
|
||||
if (this.ws.readyState === 1) this.ws.send(json);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
isAlive(): boolean {
|
||||
return this.ws.readyState === 1;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.ws.close();
|
||||
}
|
||||
|
||||
/** Access the underlying WebSocket (for ping/pong, etc.) */
|
||||
get rawWs(): WebSocket { return this.ws; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import type WebSocket from 'ws';
|
||||
import type { Server as HttpServer } from 'http';
|
||||
import type { Server as HttpsServer } from 'https';
|
||||
import { verifyWebSocketToken } from '../auth.js';
|
||||
import { WebSocketConnection } from './websocket-connection.js';
|
||||
import type { ClientConnection } from './client-connection.js';
|
||||
import type { ClientMessage } from '../types/messages.js';
|
||||
|
||||
/**
|
||||
* WebSocketTransport — manages the WebSocket server lifecycle.
|
||||
*
|
||||
* Emits:
|
||||
* 'connection' (conn: ClientConnection) — new authenticated client connected
|
||||
* 'message' (conn: ClientConnection, msg: ClientMessage) — parsed message from client
|
||||
*/
|
||||
export class WebSocketTransport extends EventEmitter {
|
||||
private wss: WebSocketServer | null = null;
|
||||
private pingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Create WebSocketServer on /ws path with JWT verification and ping/pong keepalive. */
|
||||
setup(server: HttpServer | HttpsServer): void {
|
||||
this.wss = new WebSocketServer({
|
||||
server,
|
||||
path: '/ws',
|
||||
verifyClient: ({ req }, cb) => {
|
||||
const url = new URL(req.url!, `http://${req.headers.host}`);
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token || !verifyWebSocketToken(token)) {
|
||||
cb(false, 401, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
cb(true);
|
||||
},
|
||||
});
|
||||
|
||||
this.wss.on('connection', (ws: WebSocket) => {
|
||||
const conn = new WebSocketConnection(ws);
|
||||
|
||||
this.emit('connection', conn);
|
||||
|
||||
ws.on('message', (raw: Buffer | ArrayBuffer | Buffer[]) => {
|
||||
let msg: ClientMessage;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString()) as ClientMessage;
|
||||
} catch {
|
||||
conn.send({ type: 'error', error: 'Invalid JSON' });
|
||||
return;
|
||||
}
|
||||
this.emit('message', conn, msg);
|
||||
});
|
||||
});
|
||||
|
||||
// Ping/pong keepalive every 30s
|
||||
this.pingInterval = setInterval(() => {
|
||||
if (!this.wss) return;
|
||||
for (const ws of this.wss.clients) {
|
||||
if (ws.readyState === 1) {
|
||||
ws.ping();
|
||||
}
|
||||
}
|
||||
}, 30_000);
|
||||
this.pingInterval.unref();
|
||||
}
|
||||
|
||||
/** Shut down the WebSocket server and stop keepalive. */
|
||||
destroy(): void {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval);
|
||||
this.pingInterval = null;
|
||||
}
|
||||
if (this.wss) {
|
||||
this.wss.close();
|
||||
this.wss = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "../dist/server",
|
||||
"rootDir": ".",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["../node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ToolStatus } from './messages.js';
|
||||
|
||||
export interface AdapterCapabilities {
|
||||
supportsPlanMode: boolean;
|
||||
supportsPermissionModes: boolean;
|
||||
supportsInterrupt: boolean;
|
||||
supportsResume: boolean;
|
||||
supportsAttach: boolean;
|
||||
supportsStatusLine: boolean;
|
||||
supportsImages: boolean;
|
||||
supportsStreaming: boolean;
|
||||
maxContextWindow: number;
|
||||
permissionModeType?: 'cycle' | 'toggle';
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
sessionId: string;
|
||||
cwd: string | null;
|
||||
lastModified?: number | string;
|
||||
firstPrompt?: string | null;
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
value: string;
|
||||
label: string;
|
||||
contextWindow?: number;
|
||||
}
|
||||
|
||||
export interface PermissionModeInfo {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface EffortLevelInfo {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface AdapterInfo {
|
||||
id: string;
|
||||
displayName: string;
|
||||
available: boolean;
|
||||
capabilities?: AdapterCapabilities;
|
||||
}
|
||||
|
||||
export interface ReconnectState {
|
||||
tools: Record<string, ToolStatus>;
|
||||
pendingRequests: Array<{
|
||||
type: 'permission' | 'question';
|
||||
requestId: string;
|
||||
toolName?: string;
|
||||
input?: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export type ServerMessageType =
|
||||
| 'session-created' | 'session-state' | 'text-delta' | 'thinking'
|
||||
| 'tool-start' | 'tool-done' | 'tool-updates' | 'message-complete'
|
||||
| 'permission-request' | 'permission-dismissed' | 'history-load'
|
||||
| 'turn-complete' | 'status-update' | 'mode-updated'
|
||||
| 'compacting' | 'compact-done' | 'session-error' | 'session-ended'
|
||||
| 'client-id' | 'pending-notifications' | 'error'
|
||||
| 'review-started' | 'review-ended';
|
||||
|
||||
export interface ServerMessage {
|
||||
type: ServerMessageType;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type ClientMessageType =
|
||||
| 'query' | 'permission-response' | 'ask-response' | 'abort'
|
||||
| 'reconnect' | 'set-permission-mode' | 'plan-response';
|
||||
|
||||
export interface ClientMessage {
|
||||
type: ClientMessageType;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QueryOptions {
|
||||
adapter?: string;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
sessionId?: string;
|
||||
permissionMode?: string;
|
||||
effort?: string;
|
||||
images?: string[];
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
export type PermissionBehavior = 'allow' | 'allow_session' | 'deny';
|
||||
|
||||
export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: MessageContent[];
|
||||
interrupted?: boolean;
|
||||
plan?: string;
|
||||
senderClientId?: string;
|
||||
id?: string; // unique message ID
|
||||
adapter?: string; // 'claude' | 'codex'
|
||||
timestamp?: string; // ISO 8601
|
||||
}
|
||||
|
||||
export type MessageContent =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
|
||||
| { type: 'tool_result'; tool_use_id: string; content: string };
|
||||
|
||||
export interface ToolStatus {
|
||||
name: string;
|
||||
input?: Record<string, unknown>;
|
||||
status: 'running' | 'success' | 'error' | 'interrupted';
|
||||
result?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SessionStatus {
|
||||
contextPercent: number;
|
||||
model: string;
|
||||
cost: number;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export const WS = {
|
||||
// Client → Server
|
||||
QUERY: 'query',
|
||||
PERMISSION_RESPONSE: 'permission-response',
|
||||
ASK_RESPONSE: 'ask-response',
|
||||
ABORT: 'abort',
|
||||
RECONNECT: 'reconnect',
|
||||
SET_PERMISSION_MODE: 'set-permission-mode',
|
||||
SET_MODEL: 'set-model',
|
||||
PLAN_RESPONSE: 'plan-response',
|
||||
// Server → Client
|
||||
SESSION_STATE: 'session-state',
|
||||
SESSION_CREATED: 'session-created',
|
||||
TEXT_DELTA: 'text-delta',
|
||||
THINKING: 'thinking',
|
||||
TOOL_START: 'tool-start',
|
||||
TOOL_DONE: 'tool-done',
|
||||
MESSAGE_COMPLETE: 'message-complete',
|
||||
TOOL_UPDATES: 'tool-updates',
|
||||
TURN_COMPLETE: 'turn-complete',
|
||||
PERMISSION_REQUEST: 'permission-request',
|
||||
PERMISSION_DISMISSED: 'permission-dismissed',
|
||||
HISTORY_LOAD: 'history-load',
|
||||
STATUS_UPDATE: 'status-update',
|
||||
MODE_UPDATED: 'mode-updated',
|
||||
COMPACTING: 'compacting',
|
||||
COMPACT_DONE: 'compact-done',
|
||||
SESSION_ERROR: 'session-error',
|
||||
SESSION_ENDED: 'session-ended',
|
||||
CLIENT_ID: 'client-id',
|
||||
PENDING_NOTIFICATIONS: 'pending-notifications',
|
||||
ERROR: 'error',
|
||||
// Cross-AI Review
|
||||
REVIEW_STARTED: 'review-started',
|
||||
REVIEW_ENDED: 'review-ended',
|
||||
} as const;
|
||||
|
||||
export type WsType = typeof WS[keyof typeof WS];
|
||||
|
||||
/**
|
||||
* CLI plan approval selector option indices (ExitPlanMode).
|
||||
* Claude Code v2.1.x shows 3 options:
|
||||
* 0: "Yes, auto-accept edits" → BYPASS
|
||||
* 1: "Yes, manually approve edits" → MANUALLY_APPROVE
|
||||
* 2: "Type here to tell Claude what to change" → TEXT_FEEDBACK
|
||||
*/
|
||||
export const PLAN_OPTION = {
|
||||
BYPASS: 0,
|
||||
MANUALLY_APPROVE: 1,
|
||||
TEXT_FEEDBACK: 2,
|
||||
} as const;
|
||||
Reference in New Issue
Block a user