feat(logging): unify CCS runtime logs and dashboard viewer

- add a shared structured logging service with bounded retention

- expose native /api/logs endpoints and the dashboard /logs route

- wire focused runtime emitters, cleanup support, and feature tests

Refs #926
This commit is contained in:
Tam Nhu Tran committed 2026-04-08 15:57:15 -04:00
1 parent 548edda58b
commit 6c799b2e58
36 files changed
+1648 -48

No files matched your search

+11
View File
@@ -68,6 +68,7 @@ import { tryHandleRootCommand } from './commands/root-command-router';
import { execClaude } from './utils/shell-executor';
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
import { createLogger } from './services/logging';
// Import target adapter system
import {
@@ -321,6 +322,7 @@ async function main(): Promise<void> {
registerTarget(new ClaudeAdapter());
registerTarget(new DroidAdapter());
registerTarget(new CodexAdapter());
const cliLogger = createLogger('cli');
const args = process.argv.slice(2);
const isCompletionCommand = args[0] === '__complete';
@@ -398,6 +400,12 @@ async function main(): Promise<void> {
return;
}
cliLogger.info('command.start', 'CLI invocation started', {
command: args[0] || 'default',
argCount: args.length,
flags: args.filter((arg) => arg.startsWith('-')).slice(0, 20),
});
if (shouldPassthroughNativeCodexFlagCommand(args)) {
execNativeCodexFlagCommand(args);
return;
@@ -448,6 +456,9 @@ async function main(): Promise<void> {
recovery.showRecoveryHints();
}
} catch (err) {
cliLogger.warn('recovery.failed', 'Auto-recovery failed during CLI startup', {
message: (err as Error).message,
});
// Recovery is best-effort - don't block basic CLI functionality
console.warn('[!] Recovery failed:', (err as Error).message);
}
+6 -2
View File
@@ -19,6 +19,7 @@ import { getExistingProxy, registerSession, getRunningProxyVersion } from './ses
import { isCliproxyRunning } from './stats-fetcher';
import { getPortProcess, isCLIProxyProcess, PortProcess } from '../utils/port-utils';
import { CLIPROXY_DEFAULT_PORT } from './config-generator';
import { createLogger } from '../services/logging';
/** Detection method used to find the proxy */
export type DetectionMethod = 'http' | 'session-lock' | 'port-process' | 'http-retry';
@@ -48,6 +49,7 @@ type LogFn = (msg: string) => void;
/** No-op logger for when verbose is disabled */
const noopLog: LogFn = () => {};
const logger = createLogger('cliproxy:proxy-detector');
/**
* Detect running CLIProxy using multiple methods with fallbacks.
@@ -65,7 +67,7 @@ export async function detectRunningProxy(
port: number = CLIPROXY_DEFAULT_PORT,
verbose: boolean = false
): Promise<ProxyStatus> {
const log: LogFn = verbose ? (msg) => console.error(`[proxy-detector] ${msg}`) : noopLog;
const log: LogFn = verbose ? (msg) => logger.debug('detect.verbose', msg, { port }) : noopLog;
// Validate port - fallback to default if invalid
const validPort =
@@ -235,7 +237,9 @@ export function reclaimOrphanedProxy(
pid: number,
verbose: boolean = false
): string | null {
const log: LogFn = verbose ? (msg) => console.error(`[proxy-detector] ${msg}`) : noopLog;
const log: LogFn = verbose
? (msg) => logger.debug('reclaim.verbose', msg, { port, pid })
: noopLog;
try {
log(`Reclaiming orphaned proxy: port=${port}, pid=${pid}`);
+5 -1
View File
@@ -25,6 +25,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCliproxyDir } from './config-generator';
import { createLogger } from '../services/logging';
/** Lock file structure */
interface LockData {
@@ -51,6 +52,7 @@ type LogFn = (msg: string) => void;
/** No-op logger for when verbose is disabled */
const noopLog: LogFn = () => {};
const logger = createLogger('cliproxy:startup-lock');
/**
* Get path to startup lock file
@@ -184,7 +186,9 @@ export async function acquireStartupLock(options?: {
}): Promise<LockResult> {
const retries = options?.retries ?? 20;
const retryInterval = options?.retryInterval ?? 250;
const log: LogFn = options?.verbose ? (msg) => console.error(`[startup-lock] ${msg}`) : noopLog;
const log: LogFn = options?.verbose
? (msg) => logger.debug('lock.verbose', msg, { retries, retryInterval })
: noopLog;
log(`Attempting to acquire startup lock (max ${retries} retries, ${retryInterval}ms interval)`);
+7
View File
@@ -26,6 +26,7 @@ import {
} from './model-id-normalizer';
import { getModelMaxLevel } from './model-catalog';
import { getCcsDir } from '../utils/config-manager';
import { createLogger } from '../services/logging';
export interface ToolSanitizationProxyConfig {
/** Upstream CLIProxy URL */
@@ -153,6 +154,7 @@ export class ToolSanitizationProxy {
private readonly config: Required<ToolSanitizationProxyConfig>;
private readonly logFilePath: string;
private readonly debugMode: boolean;
private readonly logger = createLogger('cliproxy:tool-sanitization-proxy');
constructor(config: ToolSanitizationProxyConfig) {
this.config = {
@@ -207,6 +209,11 @@ export class ToolSanitizationProxy {
if (this.debugMode) {
console.error(`${prefix} ${message}`);
}
this.logger[level](level, message, {
debugMode: this.debugMode,
logFilePath: this.logFilePath,
});
}
private log(message: string): void {
+47 -29
View File
@@ -9,6 +9,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCliproxyDir } from '../cliproxy/config-generator';
import { getNativeLogsDir } from '../services/logging';
import { info, ok, warn } from '../utils/ui';
/** Default age in days for error log cleanup */
@@ -19,6 +20,10 @@ function getLogsDir(): string {
return path.join(getCliproxyDir(), 'logs');
}
function getCcsLogsDir(): string {
return getNativeLogsDir();
}
/** Format bytes to human-readable size */
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
@@ -174,18 +179,18 @@ function printHelp(): void {
console.log('');
console.log('Usage: ccs cleanup [options]');
console.log('');
console.log('Remove old CLIProxy logs to free up disk space.');
console.log('Remove old CCS and CLIProxy logs to free up disk space.');
console.log('');
console.log('Options:');
console.log(' --errors Clean error request logs (error-*.log files)');
console.log(' --errors Clean legacy CLIProxy error request logs (error-*.log files)');
console.log(' --days=N Delete error logs older than N days (default: 7)');
console.log(' --dry-run Show what would be deleted without deleting');
console.log(' --force Skip confirmation prompt');
console.log(' --help, -h Show this help message');
console.log('');
console.log('Examples:');
console.log(' ccs cleanup Interactive main log cleanup');
console.log(' ccs cleanup --errors Clean error logs older than 7 days');
console.log(' ccs cleanup Interactive CCS + CLIProxy log cleanup');
console.log(' ccs cleanup --errors Clean legacy CLIProxy error logs older than 7 days');
console.log(' ccs cleanup --errors --days=3 Clean error logs older than 3 days');
console.log(' ccs cleanup --errors --dry-run Preview error log cleanup');
console.log(' ccs cleanup --dry-run Preview main log cleanup');
@@ -207,6 +212,7 @@ export async function handleCleanupCommand(args: string[]): Promise<void> {
const force = args.includes('--force');
const cleanErrors = args.includes('--errors');
const logsDir = getLogsDir();
const ccsLogsDir = getCcsLogsDir();
// Parse --days=N option
let maxAgeDays = DEFAULT_ERROR_LOG_AGE_DAYS;
@@ -224,7 +230,7 @@ export async function handleCleanupCommand(args: string[]): Promise<void> {
if (cleanErrors) {
await handleErrorLogCleanup(logsDir, maxAgeDays, dryRun, force);
} else {
await handleMainLogCleanup(logsDir, dryRun, force);
await handleMainLogCleanup({ cliproxyLogsDir: logsDir, ccsLogsDir, dryRun, force });
}
}
@@ -319,40 +325,46 @@ async function handleErrorLogCleanup(
/**
* Handle main log cleanup (main.log and rotated files)
*/
async function handleMainLogCleanup(
logsDir: string,
dryRun: boolean,
force: boolean
): Promise<void> {
// Check if logs directory exists
if (!fs.existsSync(logsDir)) {
console.log(info('No CLIProxy logs found.'));
async function handleMainLogCleanup(options: {
cliproxyLogsDir: string;
ccsLogsDir: string;
dryRun: boolean;
force: boolean;
}): Promise<void> {
const targets = [
{ label: 'CCS Logs', dir: options.ccsLogsDir },
{ label: 'CLIProxy Logs', dir: options.cliproxyLogsDir },
].map((target) => ({
...target,
fileCount: countFiles(target.dir),
size: getDirSize(target.dir),
}));
const activeTargets = targets.filter((target) => target.fileCount > 0);
if (activeTargets.length === 0) {
console.log(info('No CCS or CLIProxy logs found.'));
return;
}
// Calculate current size
const currentSize = getDirSize(logsDir);
const fileCount = countFiles(logsDir);
const currentSize = activeTargets.reduce((sum, target) => sum + target.size, 0);
const fileCount = activeTargets.reduce((sum, target) => sum + target.fileCount, 0);
if (fileCount === 0) {
console.log(info('No log files to clean.'));
return;
console.log('');
console.log('Log Cleanup Targets:');
for (const target of activeTargets) {
console.log(` ${target.label}: ${target.fileCount} files (${formatBytes(target.size)})`);
console.log(` ${target.dir}`);
}
console.log('');
console.log(`CLIProxy Logs: ${logsDir}`);
console.log(` Files: ${fileCount}`);
console.log(` Size: ${formatBytes(currentSize)}`);
console.log('');
if (dryRun) {
if (options.dryRun) {
console.log(info('Dry run - no files deleted.'));
console.log(`Would delete ${fileCount} files (${formatBytes(currentSize)})`);
return;
}
// Confirm unless --force
if (!force) {
if (!options.force) {
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
@@ -371,13 +383,19 @@ async function handleMainLogCleanup(
}
// Perform cleanup
const { deleted, freedBytes } = cleanDirectory(logsDir);
let deleted = 0;
let freedBytes = 0;
for (const target of activeTargets) {
const result = cleanDirectory(target.dir);
deleted += result.deleted;
freedBytes += result.freedBytes;
}
console.log(ok(`Deleted ${deleted} files, freed ${formatBytes(freedBytes)}`));
// Suggest disabling logging if it was enabled
if (deleted > 0) {
console.log('');
console.log(warn('Tip: CLIProxy logging is now disabled by default.'));
console.log(' Run `ccs doctor --fix` to update your config.');
console.log(warn('Tip: CCS logging is bounded by retention, but you can lower it further.'));
console.log(' Open `ccs config` and review the Logs settings.');
}
}
+24
View File
@@ -22,6 +22,9 @@ import {
resolveDashboardUrls,
} from './config-dashboard-host';
import { parseConfigCommandArgs, showConfigCommandHelp } from './config-command-options';
import { createLogger } from '../services/logging';
const logger = createLogger('command:config');
const CONFIG_SUBCOMMAND_ROUTES: readonly NamedCommandRoute[] = [
{
@@ -123,6 +126,11 @@ export async function handleConfigCommand(
const options = parsed.options;
const verbose = options.dev;
logger.info('dashboard.launch_requested', 'Config dashboard launch requested', {
dev: Boolean(options.dev),
host: options.host || null,
port: options.port || null,
});
console.log(deps.header('CCS Config Dashboard'));
console.log('');
@@ -130,6 +138,13 @@ export async function handleConfigCommand(
// Ensure CLIProxy service is running for dashboard features
console.log(deps.info('Starting CLIProxy service...'));
const cliproxyResult = await deps.ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose);
logger.info('cliproxy.ensure_result', 'Config command checked CLIProxy availability', {
started: cliproxyResult.started,
alreadyRunning: cliproxyResult.alreadyRunning,
configRegenerated: cliproxyResult.configRegenerated,
port: cliproxyResult.port || null,
error: cliproxyResult.error || null,
});
if (cliproxyResult.started) {
if (cliproxyResult.alreadyRunning) {
@@ -210,14 +225,23 @@ export async function handleConfigCommand(
// Open browser
try {
await deps.openBrowser(urls.browserUrl, { wait: false });
logger.info('dashboard.browser_opened', 'Config dashboard browser launch attempted', {
browserUrl: urls.browserUrl,
});
console.log(deps.info('Browser opened automatically'));
} catch {
logger.warn('dashboard.browser_open_failed', 'Automatic browser launch failed', {
browserUrl: urls.browserUrl,
});
console.log(deps.info(`Open manually: ${urls.browserUrl}`));
}
console.log('');
console.log(deps.info('Press Ctrl+C to stop'));
} catch (error) {
logger.error('dashboard.launch_failed', 'Config dashboard failed to launch', {
message: (error as Error).message,
});
console.error(deps.fail(`Failed to start server: ${(error as Error).message}`));
process.exit(1);
}
+37
View File
@@ -24,6 +24,7 @@ import {
DEFAULT_OFFICIAL_CHANNELS_CONFIG,
DEFAULT_DASHBOARD_AUTH_CONFIG,
DEFAULT_IMAGE_ANALYSIS_CONFIG,
DEFAULT_LOGGING_CONFIG,
} from './unified-config-types';
import type {
UnifiedConfig,
@@ -34,6 +35,7 @@ import type {
OfficialChannelId,
DashboardAuthConfig,
ImageAnalysisConfig,
LoggingConfig,
CursorConfig,
ContinuityConfig,
} from './unified-config-types';
@@ -381,6 +383,15 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
: defaults.cliproxy.routing?.strategy,
},
},
logging: {
enabled: partial.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled,
level: partial.logging?.level ?? DEFAULT_LOGGING_CONFIG.level,
rotate_mb: partial.logging?.rotate_mb ?? DEFAULT_LOGGING_CONFIG.rotate_mb,
retain_days: partial.logging?.retain_days ?? DEFAULT_LOGGING_CONFIG.retain_days,
redact: partial.logging?.redact ?? DEFAULT_LOGGING_CONFIG.redact,
live_buffer_size:
partial.logging?.live_buffer_size ?? DEFAULT_LOGGING_CONFIG.live_buffer_size,
},
preferences: {
...defaults.preferences,
...partial.preferences,
@@ -657,6 +668,19 @@ function generateYamlWithComments(config: UnifiedConfig): string {
);
lines.push('');
if (config.logging) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Logging: CCS-owned structured runtime logs');
lines.push('# Current file: ~/.ccs/logs/current.jsonl');
lines.push('# Archives rotate automatically and are pruned by retain_days.');
lines.push('# This is separate from cliproxy.logging, which controls CLIProxy runtime files.');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ logging: config.logging }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
// CLIProxy Server section (remote proxy configuration) - placed right after cliproxy
if (config.cliproxy_server) {
lines.push('# ----------------------------------------------------------------------------');
@@ -1291,6 +1315,19 @@ export function getImageAnalysisConfig(): ImageAnalysisConfig {
});
}
export function getLoggingConfig(): LoggingConfig {
const config = loadOrCreateUnifiedConfig();
return {
enabled: config.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled,
level: config.logging?.level ?? DEFAULT_LOGGING_CONFIG.level,
rotate_mb: config.logging?.rotate_mb ?? DEFAULT_LOGGING_CONFIG.rotate_mb,
retain_days: config.logging?.retain_days ?? DEFAULT_LOGGING_CONFIG.retain_days,
redact: config.logging?.redact ?? DEFAULT_LOGGING_CONFIG.redact,
live_buffer_size: config.logging?.live_buffer_size ?? DEFAULT_LOGGING_CONFIG.live_buffer_size,
};
}
/**
* Get cursor configuration.
* Returns defaults if not configured.
+33
View File
@@ -234,6 +234,36 @@ export interface CLIProxyConfig {
routing?: CLIProxyRoutingConfig;
}
export type LoggingLevel = 'error' | 'warn' | 'info' | 'debug';
/**
* CCS-owned structured logging configuration.
* Separate from cliproxy.logging, which controls CLIProxy runtime files.
*/
export interface LoggingConfig {
/** Enable CCS-owned structured runtime logging */
enabled: boolean;
/** Minimum level written to disk */
level: LoggingLevel;
/** Rotate current log when it reaches this size in MB */
rotate_mb: number;
/** Keep archived segments for this many days */
retain_days: number;
/** Redact sensitive values before persistence */
redact: boolean;
/** In-memory recent event buffer size for dashboard reads */
live_buffer_size: number;
}
export const DEFAULT_LOGGING_CONFIG: LoggingConfig = {
enabled: true,
level: 'info',
rotate_mb: 10,
retain_days: 7,
redact: true,
live_buffer_size: 250,
};
/**
* User preferences.
*/
@@ -812,6 +842,8 @@ export interface UnifiedConfig {
profiles: Record<string, ProfileConfig>;
/** CLIProxy configuration */
cliproxy: CLIProxyConfig;
/** CCS-owned structured logging configuration */
logging?: LoggingConfig;
/** User preferences */
preferences: PreferencesConfig;
/** WebSearch configuration */
@@ -912,6 +944,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
strategy: 'round-robin',
},
},
logging: { ...DEFAULT_LOGGING_CONFIG },
preferences: {
theme: 'system',
telemetry: false,
+14
View File
@@ -11,6 +11,9 @@
import { ExitCode, EXIT_CODE_DESCRIPTIONS } from './exit-codes';
import { isCCSError } from './error-types';
import { runCleanup } from './cleanup-registry';
import { createLogger } from '../services/logging';
const logger = createLogger('cli:error-handler');
/**
* Debug mode flag - set via CCS_DEBUG environment variable
@@ -91,6 +94,10 @@ export function handleError(error: unknown): never {
const code = getExitCode(error);
const message = formatErrorMessage(error);
logger.error('command.unhandled_error', 'Unhandled CLI error', {
exitCode: code,
error,
});
// Output error message to stderr
console.error(message);
@@ -112,6 +119,10 @@ export function handleError(error: unknown): never {
*/
export function exitWithError(message: string, code: ExitCode = ExitCode.GENERAL_ERROR): never {
runCleanup();
logger.error('command.exit_error', 'CLI exited with error', {
exitCode: code,
message,
});
console.error(`[X] ${message}`);
if (isDebugMode()) {
@@ -131,6 +142,9 @@ export function exitWithError(message: string, code: ExitCode = ExitCode.GENERAL
*/
export function exitWithSuccess(message?: string): never {
runCleanup();
logger.info('command.exit_success', 'CLI exited successfully', {
message: message || null,
});
if (message) {
console.log(`[OK] ${message}`);
}
+9
View File
@@ -12,6 +12,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { DeltaAccumulator } from './delta-accumulator';
import { getCcsDir } from '../utils/config-manager';
import { createLogger } from '../services/logging';
import {
RequestTransformer,
StreamParser,
@@ -36,6 +37,7 @@ export class GlmtTransformer {
private verbose: boolean;
private debugLog: boolean;
debugLogDir: string;
private readonly logger = createLogger('glmt:transformer');
private requestTransformer: RequestTransformer;
private streamParser: StreamParser;
@@ -125,6 +127,9 @@ export class GlmtTransformer {
return anthropicResponse;
} catch (error) {
const err = error as Error;
this.logger.error('response.transform_failed', 'GLMT response transformation failed', {
message: err.message,
});
console.error('[glmt-transformer] Response transformation error:', err);
return {
id: 'msg_error_' + Date.now(),
@@ -175,12 +180,16 @@ export class GlmtTransformer {
const redacted = this.redactSensitiveData(data);
fs.writeFileSync(filepath, JSON.stringify(redacted, null, 2) + '\n', 'utf8');
} catch (error) {
this.logger.warn('debug-log.write_failed', 'GLMT debug log write failed', {
message: (error as Error).message,
});
console.error(`[glmt-transformer] Debug log error: ${(error as Error).message}`);
}
}
private log(message: string): void {
if (this.verbose) {
this.logger.debug('transformer.verbose', message);
console.error(`[glmt-transformer] [${new Date().toTimeString().split(' ')[0]}] ${message}`);
}
}
+13
View File
@@ -0,0 +1,13 @@
export { createLogger } from './logger';
export { getResolvedLoggingConfig, invalidateLoggingConfigCache } from './log-config';
export { readLogEntries, readLogSourceSummaries, normalizeLogQueryLevel } from './log-reader';
export { pruneExpiredLogArchives } from './log-storage';
export {
ensureLoggingDirectories,
getCurrentLogPath,
getLegacyCliproxyLogsDir,
getLogArchiveDir,
getNativeLogsDir,
isPathInsideDirectory,
} from './log-paths';
export type { LogEntry, LogSourceSummary, LoggingLevel, ReadLogEntriesOptions } from './log-types';
+18
View File
@@ -0,0 +1,18 @@
import type { LogEntry } from './log-types';
let recentEntries: LogEntry[] = [];
export function pushRecentLogEntry(entry: LogEntry, maxEntries: number): void {
recentEntries.push(entry);
if (recentEntries.length > maxEntries) {
recentEntries = recentEntries.slice(recentEntries.length - maxEntries);
}
}
export function getRecentLogEntries(): LogEntry[] {
return [...recentEntries];
}
export function clearRecentLogEntries(): void {
recentEntries = [];
}
+47
View File
@@ -0,0 +1,47 @@
import * as fs from 'fs';
import { DEFAULT_LOGGING_CONFIG } from '../../config/unified-config-types';
import {
getConfigYamlPath,
getLoggingConfig as getUnifiedLoggingConfig,
} from '../../config/unified-config-loader';
import type { LoggingConfig } from './log-types';
const CACHE_RECHECK_MS = 1000;
let cachedConfig: LoggingConfig = { ...DEFAULT_LOGGING_CONFIG };
let cachedMtimeMs: number | null = null;
let lastCheckedAt = 0;
export function invalidateLoggingConfigCache(): void {
cachedConfig = { ...DEFAULT_LOGGING_CONFIG };
cachedMtimeMs = null;
lastCheckedAt = 0;
}
export function getResolvedLoggingConfig(): LoggingConfig {
const now = Date.now();
if (now - lastCheckedAt < CACHE_RECHECK_MS) {
return cachedConfig;
}
try {
const configPath = getConfigYamlPath();
const nextMtimeMs = fs.existsSync(configPath) ? fs.statSync(configPath).mtimeMs : null;
if (nextMtimeMs === cachedMtimeMs) {
lastCheckedAt = now;
return cachedConfig;
}
cachedConfig = {
...DEFAULT_LOGGING_CONFIG,
...getUnifiedLoggingConfig(),
};
cachedMtimeMs = nextMtimeMs;
lastCheckedAt = now;
return cachedConfig;
} catch {
cachedConfig = { ...DEFAULT_LOGGING_CONFIG };
cachedMtimeMs = null;
lastCheckedAt = now;
return cachedConfig;
}
}
+40
View File
@@ -0,0 +1,40 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
const LOGS_DIR = 'logs';
const ARCHIVE_DIR = 'archive';
const CURRENT_LOG_FILE = 'current.jsonl';
export function getNativeLogsDir(): string {
return path.join(getCcsDir(), LOGS_DIR);
}
export function getCurrentLogPath(): string {
return path.join(getNativeLogsDir(), CURRENT_LOG_FILE);
}
export function getLogArchiveDir(): string {
return path.join(getNativeLogsDir(), ARCHIVE_DIR);
}
export function getLegacyCliproxyLogsDir(): string {
return path.join(getCcsDir(), 'cliproxy', 'logs');
}
export function ensureLoggingDirectories(): void {
fs.mkdirSync(getNativeLogsDir(), { recursive: true });
fs.mkdirSync(getLogArchiveDir(), { recursive: true });
}
export function isPathInsideDirectory(candidatePath: string, rootDir: string): boolean {
const resolvedCandidate = path.resolve(candidatePath);
const resolvedRoot = path.resolve(rootDir);
const relative = path.relative(resolvedRoot, resolvedCandidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
export function buildArchiveLogPath(timestamp: Date = new Date()): string {
const compact = timestamp.toISOString().replace(/[:.]/g, '-');
return path.join(getLogArchiveDir(), `ccs-${compact}.jsonl.gz`);
}
+87
View File
@@ -0,0 +1,87 @@
import * as fs from 'fs';
import { getRecentLogEntries } from './log-buffer';
import { getCurrentLogPath } from './log-paths';
import {
isLoggingLevel,
type LogEntry,
type LogSourceSummary,
type ReadLogEntriesOptions,
} from './log-types';
function parseLogLine(line: string): LogEntry | null {
try {
return JSON.parse(line) as LogEntry;
} catch {
return null;
}
}
function readCurrentFileEntries(): LogEntry[] {
const currentLogPath = getCurrentLogPath();
if (!fs.existsSync(currentLogPath)) {
return [];
}
return fs
.readFileSync(currentLogPath, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map(parseLogLine)
.filter((entry): entry is LogEntry => entry !== null);
}
function dedupeEntries(entries: LogEntry[]): LogEntry[] {
const seen = new Map<string, LogEntry>();
for (const entry of entries) {
seen.set(entry.id, entry);
}
return [...seen.values()];
}
export function readLogEntries(options: ReadLogEntriesOptions = {}): LogEntry[] {
const entries = dedupeEntries([...readCurrentFileEntries(), ...getRecentLogEntries()])
.filter((entry) => (options.source ? entry.source === options.source : true))
.filter((entry) => (options.level ? entry.level === options.level : true))
.filter((entry) => {
if (!options.search) {
return true;
}
const search = options.search.toLowerCase();
return (
entry.message.toLowerCase().includes(search) ||
entry.event.toLowerCase().includes(search) ||
entry.source.toLowerCase().includes(search) ||
String(entry.processId).toLowerCase().includes(search) ||
entry.runId.toLowerCase().includes(search) ||
JSON.stringify(entry.context || {})
.toLowerCase()
.includes(search)
);
})
.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
return entries.slice(0, options.limit ?? 200);
}
export function readLogSourceSummaries(): LogSourceSummary[] {
const summaryMap = new Map<string, LogSourceSummary>();
for (const entry of readLogEntries({ limit: 500 })) {
const current = summaryMap.get(entry.source) ?? {
source: entry.source,
label: entry.source,
kind: 'native' as const,
count: 0,
lastTimestamp: null,
};
current.count += 1;
current.lastTimestamp = current.lastTimestamp ?? entry.timestamp;
summaryMap.set(entry.source, current);
}
return [...summaryMap.values()].sort((a, b) => a.label.localeCompare(b.label));
}
export function normalizeLogQueryLevel(level: string | undefined) {
return isLoggingLevel(level) ? level : undefined;
}
+62
View File
@@ -0,0 +1,62 @@
const SENSITIVE_KEY_PATTERN =
/^(authorization|cookie|set-cookie|password|password_hash|secret|token|api[_-]?key|management[_-]?key)$/i;
const MAX_STRING_LENGTH = 2000;
const MAX_DEPTH = 5;
function truncateString(value: string): string {
if (value.length <= MAX_STRING_LENGTH) {
return value;
}
return `${value.slice(0, MAX_STRING_LENGTH)}...[truncated]`;
}
function sanitizeValue(value: unknown, depth: number): unknown {
if (value === null || value === undefined) {
return value;
}
if (depth >= MAX_DEPTH) {
return '[max-depth]';
}
if (typeof value === 'string') {
return truncateString(value);
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (value instanceof Error) {
return {
name: value.name,
message: truncateString(value.message),
};
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeValue(item, depth + 1));
}
if (typeof value === 'object') {
const sanitized: Record<string, unknown> = {};
for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
sanitized[key] = SENSITIVE_KEY_PATTERN.test(key)
? '[redacted]'
: sanitizeValue(nestedValue, depth + 1);
}
return sanitized;
}
return String(value);
}
export function redactContext(
context: Record<string, unknown> | undefined
): Record<string, unknown> {
if (!context) {
return {};
}
return sanitizeValue(context, 0) as Record<string, unknown>;
}
+94
View File
@@ -0,0 +1,94 @@
import * as fs from 'fs';
import * as path from 'path';
import * as zlib from 'zlib';
import { getResolvedLoggingConfig } from './log-config';
import {
ensureLoggingDirectories,
getCurrentLogPath,
buildArchiveLogPath,
getLogArchiveDir,
} from './log-paths';
import { pushRecentLogEntry } from './log-buffer';
import { shouldWriteLogLevel, type LogEntry } from './log-types';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const PRUNE_INTERVAL_MS = 60 * 1000;
let lastPruneAt = 0;
function getRotateBytes(rotateMb: number): number {
return Math.max(1, rotateMb) * 1024 * 1024;
}
function rotateCurrentLogIfNeeded(): void {
const config = getResolvedLoggingConfig();
const currentLogPath = getCurrentLogPath();
if (!fs.existsSync(currentLogPath)) {
return;
}
const stats = fs.statSync(currentLogPath);
const ageMs = Date.now() - stats.mtimeMs;
const exceedsSize = stats.size >= getRotateBytes(config.rotate_mb);
const exceedsAge = ageMs >= ONE_DAY_MS;
if (!exceedsSize && !exceedsAge) {
return;
}
const currentContent = fs.readFileSync(currentLogPath, 'utf8');
if (!currentContent.trim()) {
fs.truncateSync(currentLogPath, 0);
return;
}
const archivePath = buildArchiveLogPath(new Date(stats.mtimeMs || Date.now()));
fs.writeFileSync(archivePath, zlib.gzipSync(currentContent), { mode: 0o600 });
fs.truncateSync(currentLogPath, 0);
}
export function pruneExpiredLogArchives(): void {
const config = getResolvedLoggingConfig();
const archiveDir = getLogArchiveDir();
if (!fs.existsSync(archiveDir)) {
return;
}
const cutoffMs = Date.now() - config.retain_days * ONE_DAY_MS;
for (const entry of fs.readdirSync(archiveDir)) {
const archivePath = path.join(archiveDir, entry);
try {
const stats = fs.lstatSync(archivePath);
if (!stats.isFile() || stats.isSymbolicLink()) {
continue;
}
if (stats.mtimeMs < cutoffMs) {
fs.unlinkSync(archivePath);
}
} catch {
continue;
}
}
}
export function appendStructuredLogEntry(entry: LogEntry): void {
const config = getResolvedLoggingConfig();
if (!config.enabled || !shouldWriteLogLevel(entry.level, config.level)) {
return;
}
try {
ensureLoggingDirectories();
rotateCurrentLogIfNeeded();
fs.appendFileSync(getCurrentLogPath(), `${JSON.stringify(entry)}\n`, {
encoding: 'utf8',
mode: 0o600,
});
pushRecentLogEntry(entry, config.live_buffer_size);
if (Date.now() - lastPruneAt >= PRUNE_INTERVAL_MS) {
pruneExpiredLogArchives();
lastPruneAt = Date.now();
}
} catch {
// Logging must never break runtime behavior.
}
}
+47
View File
@@ -0,0 +1,47 @@
import type { LoggingConfig, LoggingLevel } from '../../config/unified-config-types';
export type { LoggingConfig, LoggingLevel };
export interface LogEntry {
id: string;
timestamp: string;
level: LoggingLevel;
source: string;
event: string;
message: string;
processId: number;
runId: string;
context?: Record<string, unknown>;
}
export interface LogSourceSummary {
source: string;
label: string;
kind: 'native' | 'legacy';
count: number;
lastTimestamp: string | null;
}
export interface ReadLogEntriesOptions {
source?: string;
level?: LoggingLevel;
search?: string;
limit?: number;
}
export const LOG_LEVELS: readonly LoggingLevel[] = ['error', 'warn', 'info', 'debug'];
const LOG_LEVEL_PRIORITY: Record<LoggingLevel, number> = {
error: 0,
warn: 1,
info: 2,
debug: 3,
};
export function shouldWriteLogLevel(level: LoggingLevel, configuredLevel: LoggingLevel): boolean {
return LOG_LEVEL_PRIORITY[level] <= LOG_LEVEL_PRIORITY[configuredLevel];
}
export function isLoggingLevel(value: string | undefined): value is LoggingLevel {
return typeof value === 'string' && LOG_LEVELS.includes(value as LoggingLevel);
}
+67
View File
@@ -0,0 +1,67 @@
import { randomUUID } from 'crypto';
import { getResolvedLoggingConfig } from './log-config';
import { redactContext } from './log-redaction';
import { appendStructuredLogEntry } from './log-storage';
import type { LogEntry, LoggingLevel } from './log-types';
const processRunId = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
function createEntry(
source: string,
level: LoggingLevel,
event: string,
message: string,
context: Record<string, unknown>
): LogEntry {
const config = getResolvedLoggingConfig();
return {
id: randomUUID(),
timestamp: new Date().toISOString(),
level,
source,
event,
message,
processId: process.pid,
runId: processRunId,
context: config.redact ? redactContext(context) : context,
};
}
export interface Logger {
child(context: Record<string, unknown>): Logger;
debug(event: string, message: string, context?: Record<string, unknown>): void;
info(event: string, message: string, context?: Record<string, unknown>): void;
warn(event: string, message: string, context?: Record<string, unknown>): void;
error(event: string, message: string, context?: Record<string, unknown>): void;
}
export function createLogger(source: string, baseContext: Record<string, unknown> = {}): Logger {
const write = (
level: LoggingLevel,
event: string,
message: string,
context?: Record<string, unknown>
) => {
appendStructuredLogEntry(
createEntry(source, level, event, message, { ...baseContext, ...(context || {}) })
);
};
return {
child(context: Record<string, unknown>) {
return createLogger(source, { ...baseContext, ...context });
},
debug(event, message, context) {
write('debug', event, message, context);
},
info(event, message, context) {
write('info', event, message, context);
},
warn(event, message, context) {
write('warn', event, message, context);
},
error(event, message, context) {
write('error', event, message, context);
},
};
}
+7
View File
@@ -10,6 +10,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getCcsDir } from '../config-manager';
import { createLogger } from '../../services/logging';
const TRACE_FILE_NAME = 'websearch-trace.jsonl';
const NATIVE_WEBSEARCH_TOOL = 'WebSearch';
@@ -17,6 +18,7 @@ const DISALLOWED_TOOLS_FLAG = '--disallowedTools';
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT =
'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.';
const logger = createLogger('websearch');
function parseToolValue(rawValue: string): string[] {
return rawValue
@@ -121,6 +123,11 @@ export function appendWebSearchTrace(
}
try {
logger.info('trace.append', 'WebSearch trace event recorded', {
event,
launchId: env.CCS_WEBSEARCH_TRACE_LAUNCH_ID || null,
payload,
});
const traceFilePath = getTraceFilePath(env);
fs.mkdirSync(path.dirname(traceFilePath), { recursive: true });
fs.appendFileSync(
+16
View File
@@ -12,8 +12,10 @@ import path from 'path';
import { WebSocketServer } from 'ws';
import { setupWebSocket } from './websocket';
import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware';
import { requestLoggingMiddleware } from './middleware/request-logging-middleware';
import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync';
import { shutdownUsageAggregator } from './usage/aggregator';
import { createLogger } from '../services/logging';
export interface ServerOptions {
port: number;
@@ -28,6 +30,8 @@ export interface ServerInstance {
cleanup: () => void;
}
const logger = createLogger('web-server');
/**
* Start Express server with WebSocket support
*/
@@ -57,6 +61,7 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
next(err);
}
);
app.use(requestLoggingMiddleware);
// Session middleware (for dashboard auth)
app.use(createSessionMiddleware());
@@ -124,6 +129,12 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
// Start listening
return new Promise<ServerInstance>((resolve, reject) => {
const onError = (error: NodeJS.ErrnoException) => {
logger.error('server.listen_failed', 'Dashboard server failed to start', {
code: error.code || 'unknown',
message: error.message,
host: options.host || null,
port: options.port,
});
cleanup();
reject(new Error(formatListenError(error, options)));
};
@@ -132,6 +143,11 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
const onListening = () => {
server.off('error', onError);
logger.info('server.listening', 'Dashboard server listening', {
host: options.host || '0.0.0.0',
port: options.port,
dev: Boolean(options.dev),
});
// Usage cache loads on-demand when Analytics page is visited
// This keeps server startup instant for users who don't need analytics
resolve({ server, wss, cleanup });
@@ -0,0 +1,30 @@
import { randomUUID } from 'crypto';
import type { Request, Response, NextFunction } from 'express';
import { createLogger } from '../../services/logging';
const logger = createLogger('web-server:http');
export function requestLoggingMiddleware(req: Request, res: Response, next: NextFunction): void {
const requestId = randomUUID();
const startTime = Date.now();
res.locals.ccsRequestId = requestId;
res.setHeader('x-ccs-request-id', requestId);
const shouldSkipLogging = req.originalUrl.startsWith('/api/logs');
res.on('finish', () => {
if (shouldSkipLogging) {
return;
}
logger.info('request.completed', 'Dashboard request completed', {
requestId,
method: req.method,
path: req.originalUrl,
statusCode: res.statusCode,
durationMs: Date.now() - startTime,
remoteAddress: req.socket.remoteAddress || null,
userAgent: req.headers['user-agent'] || null,
});
});
next();
}
+2
View File
@@ -34,6 +34,7 @@ import authRoutes from './auth-routes';
import persistRoutes from './persist-routes';
import catalogRoutes from './catalog-routes';
import claudeExtensionRoutes from './claude-extension-routes';
import logsRoutes from './logs-routes';
// Create the main API router
export const apiRoutes = Router();
@@ -115,3 +116,4 @@ apiRoutes.use('/cliproxy-server', cliproxyServerRoutes);
// ==================== Misc (File API, Global Env) ====================
apiRoutes.use('/', miscRoutes);
apiRoutes.use('/logs', logsRoutes);
+99
View File
@@ -0,0 +1,99 @@
import { Router, type Request, type Response } from 'express';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
import { isLoggingLevel } from '../../services/logging/log-types';
import {
getDashboardLoggingConfig,
listDashboardLogEntries,
listDashboardLogSources,
updateDashboardLoggingConfig,
} from '../services/logs-dashboard-service';
const router = Router();
const LOGS_LOCAL_ACCESS_ERROR =
'Logs endpoints require localhost access when dashboard auth is disabled.';
router.use((req: Request, res: Response, next) => {
if (requireLocalAccessWhenAuthDisabled(req, res, LOGS_LOCAL_ACCESS_ERROR)) {
next();
}
});
router.get('/config', (_req: Request, res: Response) => {
res.json({ logging: getDashboardLoggingConfig() });
});
router.put('/config', (req: Request, res: Response) => {
const updates = req.body as Record<string, unknown>;
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
res.status(400).json({ error: 'Invalid request body. Must be an object.' });
return;
}
const {
enabled,
level,
rotate_mb: rotateMb,
retain_days: retainDays,
redact,
live_buffer_size: liveBufferSize,
} = updates;
if (enabled !== undefined && typeof enabled !== 'boolean') {
res.status(400).json({ error: 'enabled must be a boolean' });
return;
}
if (level !== undefined && !isLoggingLevel(String(level))) {
res.status(400).json({ error: 'level must be one of error, warn, info, debug' });
return;
}
const numericPairs = [
['rotate_mb', rotateMb],
['retain_days', retainDays],
['live_buffer_size', liveBufferSize],
] as const;
for (const [field, value] of numericPairs) {
if (value !== undefined && (!Number.isInteger(value) || Number(value) < 1)) {
res.status(400).json({ error: `${field} must be a positive integer` });
return;
}
}
if (redact !== undefined && typeof redact !== 'boolean') {
res.status(400).json({ error: 'redact must be a boolean' });
return;
}
res.json({
success: true,
logging: updateDashboardLoggingConfig({
enabled: enabled as boolean | undefined,
level: level as 'error' | 'warn' | 'info' | 'debug' | undefined,
rotate_mb: rotateMb as number | undefined,
retain_days: retainDays as number | undefined,
redact: redact as boolean | undefined,
live_buffer_size: liveBufferSize as number | undefined,
}),
});
});
router.get('/sources', (_req: Request, res: Response) => {
res.json({ sources: listDashboardLogSources() });
});
router.get('/entries', (req: Request, res: Response) => {
const { source, level, search, limit } = req.query;
const parsedLimit =
typeof limit === 'string' && Number.isInteger(Number(limit)) ? Number(limit) : undefined;
res.json({
entries: listDashboardLogEntries({
source: typeof source === 'string' ? source : undefined,
level: typeof level === 'string' && isLoggingLevel(level) ? level : undefined,
search: typeof search === 'string' ? search : undefined,
limit: parsedLimit,
}),
});
});
export default router;
@@ -0,0 +1,37 @@
import { mutateUnifiedConfig } from '../../config/unified-config-loader';
import {
getResolvedLoggingConfig,
invalidateLoggingConfigCache,
readLogEntries,
readLogSourceSummaries,
} from '../../services/logging';
import type { LoggingConfig } from '../../config/unified-config-types';
import type { ReadLogEntriesOptions } from '../../services/logging';
export function getDashboardLoggingConfig(): LoggingConfig {
return getResolvedLoggingConfig();
}
export function updateDashboardLoggingConfig(updates: Partial<LoggingConfig>): LoggingConfig {
const updated = mutateUnifiedConfig((config) => {
config.logging = {
...getResolvedLoggingConfig(),
...config.logging,
...updates,
};
});
invalidateLoggingConfigCache();
return {
...getResolvedLoggingConfig(),
...updated.logging,
};
}
export function listDashboardLogSources() {
return readLogSourceSummaries();
}
export function listDashboardLogEntries(options: ReadLogEntriesOptions = {}) {
return readLogEntries(options);
}
+24 -14
View File
@@ -7,12 +7,14 @@
import { WebSocketServer, WebSocket } from 'ws';
import { createFileWatcher, FileChangeEvent } from './file-watcher';
import { info, warn } from '../utils/ui';
import {
projectSelectionEvents,
type ProjectSelectionPrompt,
} from '../cliproxy/project-selection-handler';
import { deviceCodeEvents, type DeviceCodePrompt } from '../cliproxy/device-code-handler';
import { createLogger } from '../services/logging';
const logger = createLogger('web-server:websocket');
export interface WSMessage {
type: string;
@@ -36,7 +38,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
// Handle new connections
wss.on('connection', (ws) => {
clients.add(ws);
console.log(info(`[WS] Client connected (${clients.size} total)`));
logger.info('client.connected', 'WebSocket client connected', { clients: clients.size });
// Send welcome message
ws.send(JSON.stringify({ type: 'connected', timestamp: Date.now() }));
@@ -47,18 +49,20 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
const message = JSON.parse(data.toString());
handleClientMessage(ws, message);
} catch {
console.log(warn('[WS] Invalid message format'));
logger.warn('message.invalid', 'WebSocket client sent invalid JSON');
}
});
// Handle disconnect
ws.on('close', () => {
clients.delete(ws);
console.log(info(`[WS] Client disconnected (${clients.size} remaining)`));
logger.info('client.disconnected', 'WebSocket client disconnected', {
clients: clients.size,
});
});
ws.on('error', (err) => {
console.log(warn(`[WS] Error: ${err.message}`));
logger.warn('client.error', 'WebSocket client error', { message: err.message });
clients.delete(ws);
});
});
@@ -73,13 +77,15 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
// Future: selective subscriptions
break;
default:
console.log(warn(`[WS] Unknown message type: ${message.type}`));
logger.warn('message.unknown', 'WebSocket client sent unknown message type', {
type: String(message.type),
});
}
}
// Setup file watcher
const watcher = createFileWatcher((event: FileChangeEvent) => {
console.log(info(`[FS] ${event.type}: ${event.path}`));
logger.debug('file.changed', 'Dashboard file watcher detected a change', { ...event });
broadcast({
type: event.type,
path: event.path,
@@ -89,7 +95,9 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
// Listen for project selection events and broadcast to clients
const handleProjectSelectionRequired = (prompt: ProjectSelectionPrompt): void => {
console.log(info(`[WS] Broadcasting project selection prompt (session: ${prompt.sessionId})`));
logger.info('project-selection.required', 'Broadcasting project selection prompt', {
sessionId: prompt.sessionId,
});
broadcast({
type: 'projectSelectionRequired',
...prompt,
@@ -98,7 +106,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
};
const handleProjectSelectionTimeout = (sessionId: string): void => {
console.log(info(`[WS] Project selection timed out (session: ${sessionId})`));
logger.info('project-selection.timeout', 'Project selection prompt timed out', { sessionId });
broadcast({
type: 'projectSelectionTimeout',
sessionId,
@@ -110,7 +118,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
sessionId: string;
selectedId: string;
}): void => {
console.log(info(`[WS] Project selection submitted (session: ${response.sessionId})`));
logger.info('project-selection.submitted', 'Project selection submitted', response);
broadcast({
type: 'projectSelectionSubmitted',
...response,
@@ -120,7 +128,9 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
// Listen for device code events and broadcast to clients
const handleDeviceCodeReceived = (prompt: DeviceCodePrompt): void => {
console.log(info(`[WS] Broadcasting device code (session: ${prompt.sessionId})`));
logger.info('device-code.received', 'Broadcasting device code prompt', {
sessionId: prompt.sessionId,
});
broadcast({
type: 'deviceCodeReceived',
...prompt,
@@ -129,7 +139,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
};
const handleDeviceCodeCompleted = (sessionId: string): void => {
console.log(info(`[WS] Device code auth completed (session: ${sessionId})`));
logger.info('device-code.completed', 'Device code auth completed', { sessionId });
broadcast({
type: 'deviceCodeCompleted',
sessionId,
@@ -138,7 +148,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
};
const handleDeviceCodeFailed = (data: { sessionId: string; error?: string }): void => {
console.log(info(`[WS] Device code auth failed (session: ${data.sessionId})`));
logger.warn('device-code.failed', 'Device code auth failed', data);
broadcast({
type: 'deviceCodeFailed',
...data,
@@ -147,7 +157,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
};
const handleDeviceCodeExpired = (sessionId: string): void => {
console.log(info(`[WS] Device code expired (session: ${sessionId})`));
logger.info('device-code.expired', 'Device code expired', { sessionId });
broadcast({
type: 'deviceCodeExpired',
sessionId,