diff --git a/src/cli/subcommands/listen.tsx b/src/cli/subcommands/listen.tsx index cd787c2..211d0d6 100644 --- a/src/cli/subcommands/listen.tsx +++ b/src/cli/subcommands/listen.tsx @@ -11,6 +11,7 @@ import type React from "react"; import { useState } from "react"; import { getServerUrl } from "../../agent/client"; import { settingsManager } from "../../settings-manager"; +import { RemoteSessionLog } from "../../websocket/listen-log"; import { registerWithCloud } from "../../websocket/listen-register"; import { ListenerStatusUI } from "../components/ListenerStatusUI"; @@ -46,17 +47,6 @@ function formatTimestamp(): string { return `${h}:${m}:${s}.${ms}`; } -function debugWsLogger( - direction: "send" | "recv", - label: "client" | "protocol" | "control" | "lifecycle", - event: unknown, -): void { - const arrow = direction === "send" ? "\u2192 send" : "\u2190 recv"; - const tag = label === "client" ? "" : ` (${label})`; - const json = JSON.stringify(event); - console.log(`[${formatTimestamp()}] ${arrow}${tag} ${json}`); -} - export async function runListenSubcommand(argv: string[]): Promise { // Parse arguments const { values } = parseArgs({ @@ -139,6 +129,11 @@ export async function runListenSubcommand(argv: string[]): Promise { } } + // Session log (always written to ~/.letta/logs/remote/) + const sessionLog = new RemoteSessionLog(); + sessionLog.init(); + console.log(`Log file: ${sessionLog.path}`); + try { // Get device ID const deviceId = settingsManager.getOrCreateDeviceId(); @@ -153,6 +148,10 @@ export async function runListenSubcommand(argv: string[]): Promise { return 1; } + sessionLog.log(`Session started (debug=${debugMode})`); + sessionLog.log(`deviceId: ${deviceId}`); + sessionLog.log(`connectionName: ${connectionName}`); + // Register with cloud const serverUrl = getServerUrl(); @@ -163,6 +162,7 @@ export async function runListenSubcommand(argv: string[]): Promise { console.log(`[${formatTimestamp()}] deviceId: ${deviceId}`); console.log(`[${formatTimestamp()}] connectionName: ${connectionName}`); } + sessionLog.log(`Registering with ${serverUrl}/v1/environments/register`); const { connectionId, wsUrl } = await registerWithCloud({ serverUrl, @@ -171,6 +171,9 @@ export async function runListenSubcommand(argv: string[]): Promise { connectionName, }); + sessionLog.log(`Registered: connectionId=${connectionId}`); + sessionLog.log(`wsUrl: ${wsUrl}`); + if (debugMode) { console.log(`[${formatTimestamp()}] Registered successfully`); console.log(`[${formatTimestamp()}] connectionId: ${connectionId}`); @@ -184,6 +187,21 @@ export async function runListenSubcommand(argv: string[]): Promise { "../../websocket/listen-client" ); + // WS event logger: always writes to file, console only in --debug + const wsEventLogger = ( + direction: "send" | "recv", + label: "client" | "protocol" | "control" | "lifecycle", + event: unknown, + ): void => { + sessionLog.wsEvent(direction, label, event); + if (debugMode) { + const arrow = direction === "send" ? "\u2192 send" : "\u2190 recv"; + const tag = label === "client" ? "" : ` (${label})`; + const json = JSON.stringify(event); + console.log(`[${formatTimestamp()}] ${arrow}${tag} ${json}`); + } + }; + if (debugMode) { // Debug mode: plain-text event logging, no Ink UI await startListenerClient({ @@ -191,26 +209,33 @@ export async function runListenSubcommand(argv: string[]): Promise { wsUrl, deviceId, connectionName, - onWsEvent: debugWsLogger, + onWsEvent: wsEventLogger, onStatusChange: (status) => { + sessionLog.log(`status: ${status}`); console.log(`[${formatTimestamp()}] status: ${status}`); }, onConnected: () => { + sessionLog.log("Connected. Awaiting instructions."); console.log( `[${formatTimestamp()}] Connected. Awaiting instructions.`, ); console.log(""); }, onRetrying: (attempt, _maxAttempts, nextRetryIn) => { + sessionLog.log( + `Reconnecting (attempt ${attempt}, retry in ${Math.round(nextRetryIn / 1000)}s)`, + ); console.log( `[${formatTimestamp()}] Reconnecting (attempt ${attempt}, retry in ${Math.round(nextRetryIn / 1000)}s)`, ); }, onDisconnected: () => { + sessionLog.log("Disconnected."); console.log(`[${formatTimestamp()}] Disconnected.`); process.exit(1); }, onError: (error: Error) => { + sessionLog.log(`Error: ${error.message}`); console.error(`[${formatTimestamp()}] Error: ${error.message}`); process.exit(1); }, @@ -244,24 +269,32 @@ export async function runListenSubcommand(argv: string[]): Promise { wsUrl, deviceId, connectionName, + onWsEvent: wsEventLogger, onStatusChange: (status) => { + sessionLog.log(`status: ${status}`); clearRetryStatusCallback?.(); updateStatusCallback?.(status); }, onConnected: () => { + sessionLog.log("Connected. Awaiting instructions."); clearRetryStatusCallback?.(); updateStatusCallback?.("idle"); }, onRetrying: (attempt, _maxAttempts, nextRetryIn) => { + sessionLog.log( + `Reconnecting (attempt ${attempt}, retry in ${Math.round(nextRetryIn / 1000)}s)`, + ); updateRetryStatusCallback?.(attempt, nextRetryIn); }, onDisconnected: () => { + sessionLog.log("Disconnected."); unmount(); console.log("\n\u2717 Listener disconnected"); console.log("Connection to Letta Cloud was lost.\n"); process.exit(1); }, onError: (error: Error) => { + sessionLog.log(`Error: ${error.message}`); unmount(); console.error(`\n\u2717 Listener error: ${error.message}\n`); process.exit(1); @@ -274,9 +307,9 @@ export async function runListenSubcommand(argv: string[]): Promise { // Never resolves - runs until Ctrl+C }); } catch (error) { - console.error( - `Failed to start listener: ${error instanceof Error ? error.message : String(error)}`, - ); + const msg = error instanceof Error ? error.message : String(error); + sessionLog.log(`FATAL: ${msg}`); + console.error(`Failed to start listener: ${msg}`); return 1; } } diff --git a/src/websocket/listen-log.ts b/src/websocket/listen-log.ts new file mode 100644 index 0000000..d86998e --- /dev/null +++ b/src/websocket/listen-log.ts @@ -0,0 +1,104 @@ +/** + * Always-on file logger for letta remote sessions. + * Writes to ~/.letta/logs/remote/{timestamp}.log regardless of --debug mode. + * Debug mode additionally prints to console; this file always captures the log. + */ + +import { + appendFileSync, + existsSync, + mkdirSync, + readdirSync, + unlinkSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const REMOTE_LOG_DIR = join(homedir(), ".letta", "logs", "remote"); +const MAX_LOG_FILES = 10; + +function formatTimestamp(): string { + const now = new Date(); + const h = String(now.getHours()).padStart(2, "0"); + const m = String(now.getMinutes()).padStart(2, "0"); + const s = String(now.getSeconds()).padStart(2, "0"); + const ms = String(now.getMilliseconds()).padStart(3, "0"); + return `${h}:${m}:${s}.${ms}`; +} + +function pruneOldLogs(): void { + try { + if (!existsSync(REMOTE_LOG_DIR)) return; + const files = readdirSync(REMOTE_LOG_DIR) + .filter((f) => f.endsWith(".log")) + .sort(); + if (files.length >= MAX_LOG_FILES) { + const toDelete = files.slice(0, files.length - MAX_LOG_FILES + 1); + for (const file of toDelete) { + try { + unlinkSync(join(REMOTE_LOG_DIR, file)); + } catch { + // best-effort cleanup + } + } + } + } catch { + // best-effort cleanup + } +} + +export class RemoteSessionLog { + readonly path: string; + private dirCreated = false; + + constructor() { + const now = new Date(); + const stamp = now.toISOString().replace(/[:.]/g, "-"); + this.path = join(REMOTE_LOG_DIR, `${stamp}.log`); + } + + /** Must be called once at startup to create the directory and prune old logs. */ + init(): void { + this.ensureDir(); + pruneOldLogs(); + } + + /** Log a line to the file (best-effort, sync). */ + log(message: string): void { + const line = `[${formatTimestamp()}] ${message}\n`; + this.appendLine(line); + } + + /** Log a WS event in the same format as debugWsLogger. */ + wsEvent( + direction: "send" | "recv", + label: "client" | "protocol" | "control" | "lifecycle", + event: unknown, + ): void { + const arrow = direction === "send" ? "\u2192 send" : "\u2190 recv"; + const tag = label === "client" ? "" : ` (${label})`; + const json = JSON.stringify(event); + this.log(`${arrow}${tag} ${json}`); + } + + private appendLine(line: string): void { + this.ensureDir(); + try { + appendFileSync(this.path, line, { encoding: "utf8" }); + } catch { + // best-effort + } + } + + private ensureDir(): void { + if (this.dirCreated) return; + try { + if (!existsSync(REMOTE_LOG_DIR)) { + mkdirSync(REMOTE_LOG_DIR, { recursive: true }); + } + this.dirCreated = true; + } catch { + // silently ignore + } + } +}