Files
clawtap/server/adapters/interface.ts
T
izackp 2ded310472 feat: plan-approval overlay, auth-expiry handling, cert download, login reset
- Add a dedicated plan-approval bottom sheet (interactive-prompt) driven by
  ExitPlanMode's now-4-option layout (Claude Code v2.1.177), with
  approve/YOLO/reject/feedback wired through respondPlan(requestId).
- PermissionManager: stop auto-expiring AskUserQuestion/plan prompts and stop
  releasing pending prompts on client disconnect — they reflect real CLI
  state and must survive reconnects.
- Detect server-rejected tokens (401/403 or failed WS handshake) and bounce
  back to the login screen via a new AUTH_EXPIRED_EVENT.
- Add `/cert` endpoint + Settings entry so phones can download/trust the
  self-signed HTTPS cert directly; fix cert IP detection on non-macOS hosts.
- Add `clawtap reset-login` command and `/api/auth/reset-attempts` endpoint
  to clear login rate-limit lockouts.
- Codex adapter: only forward recognized keystrokes/option indices to tmux,
  never the synthetic "deny" dismissal signal.
- PWA: actively poll for service-worker updates (iOS standalone apps don't
  reliably check on navigation).
- update-service.sh: install tmux if missing, prompt to create the env file
  instead of failing, add --skip-firewall, and improve logging.
2026-06-15 16:30:24 -04:00

175 lines
6.9 KiB
TypeScript

// 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; pendingRekey?: boolean }> { 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, requestId?: 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 ---
/** Update the port used by hooks (called when port fallback changes the port). */
setHookPort(port: number | string): void {}
/** Install adapter-specific hooks (e.g., write to CLI settings). No server needed. */
installHooks(): void {}
/** Remove adapter-specific hooks. No server needed. */
uninstallHooks(): void {}
/** Respond to an interactive prompt (permission, question, plan, etc). */
respondInteractivePrompt(requestId: string, selectedOption?: string, textValue?: string): 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; }
}