diff --git a/CHANGELOG.md b/CHANGELOG.md index 543c971..24e1744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +## [0.1.9] β€” 2026-03-27 + +### Added + +- **CLI Power-User Features**: New terminal-native commands for managing sessions directly from the workstation: + - `cloudcode status`: List all active sessions with agent, path, and uptime. + - `cloudcode attach `: Instantly re-enter any session's native `tmux` environment. + - `cloudcode logs `: Stream clean semantic Markdown logs to the terminal. Use `-f` to follow live. + - `cloudcode stop `: Gracefully terminate an active agent session with SIGINT. + - `cloudcode init`: New diagnostic tool to verify dependencies (Node, Go, tmux, git) and auto-detect installed agents. + +### Improved + +- **Bit-Perfect PTY Streaming**: Refactored the backend bridge to decouple raw binary data from decoded text. The live terminal now receives a bit-perfect stream, eliminating dropped or corrupted multi-byte characters (emojis, complex symbols). +- **Enhanced Transcript Preservation**: Refined the Markdown transcript filters to preserve agent reasoning states like "Thinking," "Analyzing," and "Working," providing a more comprehensive history of the agent's thought process. +- **Robust Agent Detection**: The `init` command now uses multiple detection strategies (version check + `command -v`) to find installed agents even if they don't support standard version flags. + +### Fixed + +- **PTY Bridge Stability**: Wrapped decoding logic in robust error handlers to prevent malformed PTY data from crashing the session line-processor. +- **CLI SQL Safety**: All new terminal commands use strictly parameterized queries to prevent SQL injection. +- **Log Follow Cleanup**: The `cloudcode logs -f` command now correctly cleans up intervals and event listeners on exit. + ## [0.1.8] β€” 2026-03-25 ### Added diff --git a/README.md b/README.md index 73102eb..2be3f4b 100644 --- a/README.md +++ b/README.md @@ -69,19 +69,10 @@ cloudcode run gemini-cli --rc npm install -g @humans-of-ai/cloudcode ``` -Verify the install: +Verify the install and check your system for dependencies: ```bash -cloudcode --version -``` - -### Build from source - -```bash -git clone https://github.com/alexchaomander/CloudCode.git -cd CloudCode -npm install -npm run install:cli +cloudcode init ``` --- @@ -100,6 +91,18 @@ cloudcode start --rc ``` Runs CloudCode as a background server so you can launch agents later from your phone. +--- + +## CLI Power-User Features + +Beyond the dashboard, you can manage your sessions directly from the terminal: + +- **`cloudcode status`**: List all active sessions, their agents, and uptime. +- **`cloudcode attach `**: Directly attach to a session's native `tmux` environment. +- **`cloudcode logs `**: Stream clean semantic transcripts (Markdown) of a session. Use `-f` to follow live. +- **`cloudcode stop `**: Gracefully stop an active agent session. +- **`cloudcode init`**: Verify your environment (Node, Go, tmux, git) and detect installed agents. + ### Share an existing session ```bash cloudcode share diff --git a/backend/src/cli.ts b/backend/src/cli.ts index d4ad736..d8b1b5b 100644 --- a/backend/src/cli.ts +++ b/backend/src/cli.ts @@ -16,6 +16,7 @@ import { syncSessionStatus, createSession } from './sessions/service.js'; import { sidecarManager } from './terminal/sidecar-manager.js'; import { db } from './db/index.js'; import { startTunnel, stopTunnel } from './utils/tunnel.js'; +import { readTranscript } from './terminal/transcript-store.js'; const program = new Command(); @@ -56,6 +57,210 @@ async function startServer(options: { port: number; host: string; sidecarSocketP return app; } +program + .command('init') + .description('Check dependencies and initialize CloudCode environment') + .action(async () => { + console.log(chalk.blue.bold('\nπŸ” CloudCode System Check\n')); + + const checkDep = (name: string, cmd: string) => { + try { + const version = execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + console.log(`${chalk.green('βœ…')} ${chalk.bold(name.padEnd(10))} Found (${chalk.dim(version.split('\n')[0])})`); + return true; + } catch { + console.log(`${chalk.red('❌')} ${chalk.bold(name.padEnd(10))} Not found`); + return false; + } + }; + + const deps = [ + { name: 'Node.js', cmd: 'node --version' }, + { name: 'Go', cmd: 'go version' }, + { name: 'tmux', cmd: 'tmux -V' }, + { name: 'git', cmd: 'git --version' }, + ]; + + let allDepsFound = true; + for (const dep of deps) { + if (!checkDep(dep.name, dep.cmd)) allDepsFound = false; + } + + if (!allDepsFound) { + console.log(chalk.yellow('\n⚠️ Some dependencies are missing. Please install them to ensure CloudCode works correctly.')); + } + + console.log(chalk.blue.bold('\nπŸ€– Agent Detection\n')); + const agents = [ + { name: 'Claude Code', cmd: 'claude --version', slug: 'claude-code' }, + { name: 'Gemini CLI', cmd: 'gemini --version', slug: 'gemini-cli' }, + { name: 'Copilot', cmd: 'copilot version', slug: 'github-copilot' }, + ]; + + for (const agent of agents) { + try { + // Try running the version command + execSync(agent.cmd, { stdio: 'ignore' }); + console.log(`${chalk.green('βœ…')} ${chalk.bold(agent.name.padEnd(12))} Detected`); + } catch { + // Fallback: check if the binary exists in PATH at all + try { + const binaryName = agent.cmd.split(' ')[0]; + execSync(`command -v ${binaryName}`, { stdio: 'ignore' }); + console.log(`${chalk.green('βœ…')} ${chalk.bold(agent.name.padEnd(12))} Detected (Binary found)`); + } catch { + console.log(`${chalk.gray('βž–')} ${chalk.dim(agent.name.padEnd(12))} Not detected`); + } + } + } + + runMigrations(); + const admin = getFirstAdminUser(); + if (!admin) { + console.log(chalk.yellow('\nπŸ‘€ No admin user found.')); + console.log(chalk.dim(' Run "cloudcode run " or "cloudcode start" to create your first user.')); + } else { + console.log(chalk.green(`\nβœ… Admin user "${admin.username}" is ready.`)); + } + + console.log(chalk.blue.bold('\n🌐 Networking Check\n')); + try { + execSync('tailscale version', { stdio: 'ignore' }); + console.log(`${chalk.green('βœ…')} ${chalk.bold('Tailscale')} Detected (Recommended for secure remote access)`); + } catch { + console.log(`${chalk.yellow('ℹ️')} ${chalk.bold('Tailscale')} Not detected (Optional)`); + } + + console.log(chalk.cyan('\n✨ Initialization complete!')); + console.log(chalk.gray('Use ') + chalk.white('cloudcode run claude-code --rc') + chalk.gray(' to start your first session.\n')); + }); + +program + .command('status') + .description('Show status of active CloudCode sessions') + .action(async () => { + runMigrations(); + await syncSessionStatus(); + + const sessions = db.prepare(` + SELECT s.*, p.name as profile_name + FROM sessions s + JOIN agent_profiles p ON p.id = s.agent_profile_id + WHERE s.status = 'running' + ORDER BY s.created_at DESC + `).all() as any[]; + + if (sessions.length === 0) { + console.log(chalk.gray('\nNo active sessions.')); + return; + } + + console.log(chalk.blue.bold(`\nπŸš€ Active CloudCode Sessions (${sessions.length}):\n`)); + sessions.forEach(s => { + const uptime = Math.floor((Date.now() - new Date(s.started_at).getTime()) / 60000); + console.log(`${chalk.green('●')} ${chalk.bold(s.title)} [${chalk.cyan(s.public_id)}]`); + console.log(` ${chalk.dim('Agent:')} ${s.profile_name}`); + console.log(` ${chalk.dim('Path :')} ${s.workdir}`); + console.log(` ${chalk.dim('Uptime:')} ${uptime}m\n`); + }); + }); + +program + .command('attach') + .description('Attach to a session via tmux') + .argument('', 'Public ID of the session (e.g. 5x7h2k9)') + .action(async (id: string) => { + runMigrations(); + const session = db.prepare('SELECT tmux_session_name FROM sessions WHERE public_id = ?').get(id) as { tmux_session_name: string } | undefined; + + if (!session) { + console.error(chalk.red(`Error: Session "${id}" not found.`)); + process.exit(1); + } + + console.log(chalk.yellow(`Attaching to session ${id}... (Ctrl-b d to detach)`)); + try { + spawn('tmux', ['attach-session', '-t', session.tmux_session_name], { + stdio: 'inherit' + }).on('exit', () => { + process.exit(0); + }); + } catch (err) { + console.error(chalk.red('Failed to attach:'), err); + process.exit(1); + } + }); + +program + .command('stop') + .description('Stop an active session') + .argument('', 'Public ID of the session') + .action(async (id: string) => { + runMigrations(); + const session = db.prepare('SELECT id, public_id, title FROM sessions WHERE public_id = ?').get(id) as { id: string; public_id: string; title: string } | undefined; + + if (!session) { + console.error(chalk.red(`Error: Session "${id}" not found.`)); + process.exit(1); + } + + const admin = getFirstAdminUser(); + if (!admin) { + console.error(chalk.red('Error: Admin user not found.')); + process.exit(1); + } + + console.log(chalk.yellow(`Stopping session "${session.title}" [${session.public_id}]...`)); + try { + const { stopSession } = await import('./sessions/service.js'); + await stopSession(session.id, admin.id); + console.log(chalk.green('βœ… Session stopped.')); + } catch (err) { + console.error(chalk.red('Failed to stop session:'), err); + process.exit(1); + } + }); + +program + .command('logs') + .description('Show clean semantic logs for a session') + .argument('', 'Public ID of the session') + .option('-f, --follow', 'Follow log output', false) + .action(async (id: string, options: { follow: boolean }) => { + runMigrations(); + const session = db.prepare('SELECT id, title FROM sessions WHERE public_id = ?').get(id) as { id: string; title: string } | undefined; + + if (!session) { + console.error(chalk.red(`Error: Session "${id}" not found.`)); + process.exit(1); + } + + const printLogs = async () => { + try { + const logs = await readTranscript(session.id, { asMarkdown: true }); + process.stdout.write('\x1b[H\x1b[2J'); // Clear screen + console.log(chalk.blue.bold(`πŸ“ Transcript for: ${session.title} [${id}]\n`)); + console.log(logs || chalk.dim('(Empty)')); + if (options.follow) { + console.log(chalk.yellow('\nWatching for changes... (Ctrl+C to stop)')); + } + } catch (err) { + console.error(chalk.red('\nFailed to read transcript:'), err); + if (!options.follow) process.exit(1); + } + }; + + await printLogs(); + + if (options.follow) { + const interval = setInterval(printLogs, 2000); + process.on('SIGINT', () => { + clearInterval(interval); + process.exit(0); + }); + } + }); + program .command('start') .description('Start the CloudCode server') diff --git a/backend/src/sessions/service.ts b/backend/src/sessions/service.ts index 4cea961..41c164b 100644 --- a/backend/src/sessions/service.ts +++ b/backend/src/sessions/service.ts @@ -57,7 +57,8 @@ async function startTranscriptRecorder(sessionId: string, sessionName: string): if (transcriptRecorders.has(sessionId)) return; const recorder = await sidecarManager.openStream(sessionName, 160, 48, { - onOutput: ({ text }) => { + onData: () => {}, // Raw bytes not needed for recorder + onText: (text) => { void appendTranscript(sessionId, text).catch((err) => { console.error(`Failed to append transcript for session ${sessionId}:`, err) }) diff --git a/backend/src/terminal/readable-transcript.ts b/backend/src/terminal/readable-transcript.ts index b97d673..73257f6 100644 --- a/backend/src/terminal/readable-transcript.ts +++ b/backend/src/terminal/readable-transcript.ts @@ -28,23 +28,25 @@ function isUiChromeLine(line: string): boolean { const trimmed = line.trim() if (!trimmed) return false - if (trimmed.includes('cc-') || trimmed.includes('projects/')) return true + // Technical escape sequences or internal UI identifiers if (trimmed.includes(';2c0;276;0c')) return true if (trimmed.includes('[?12l') || trimmed.includes('[?25h') || trimmed.includes('[>c')) return true - if (trimmed.includes('to accept edits')) return true + + // NEVER strip lines that look like Markdown structural elements (lists, headers, etc) + if (isMarkdownStructuralLine(trimmed)) return false const lowered = trimmed.toLowerCase() + + // Headers and repetitive status bars that aren't useful in a static transcript const chrome = [ - 'gemini cli', 'claude code', 'logged in', 'openai codex', 'copilot cli', - '/auth', '/upgrade', 'type your message', 'shift+tab', 'shortcuts', - 'analyzing', 'thinking', 'working', 'completed', 'no sandbox', '/model', - 'delegate to agent', 'subagent', 'termination reason', 'goal', 'result:' + 'shift+tab', 'shortcuts', 'no sandbox' ] if (chrome.some((needle) => lowered.includes(needle))) return true - if (/^[0-9. ]+$/.test(trimmed) && (trimmed.includes('.') || trimmed.length < 5)) return true - if (trimmed.length < 3 && !/^[A-Z0-9]+$/.test(trimmed)) return true + // Hide standalone progress percentages or short numeric noise (e.g. " 10.5% ") + // but ONLY if they aren't part of a Markdown structure. + if (/^[0-9.% ]+$/.test(trimmed) && trimmed.length < 8) return true return false } diff --git a/backend/src/terminal/routes.ts b/backend/src/terminal/routes.ts index 13c1a76..a43ee19 100644 --- a/backend/src/terminal/routes.ts +++ b/backend/src/terminal/routes.ts @@ -198,14 +198,16 @@ const terminalRoutes: FastifyPluginAsync = async (fastify) => { await ptySession?.close().catch(() => {}); attachedSession = session; ptySession = await sidecarManager.openStream(tmuxSessionName, lastSize.cols, lastSize.rows, { - onOutput: ({ text, dataBase64 }) => { + onData: (dataBase64) => { + if (ws.readyState !== 1) return; + ws.send(JSON.stringify({ type: 'terminal.output', dataBase64 })); + }, + onText: (text) => { if (session && !isMirrorOnly && !hasTranscriptRecorder(session.id)) { void appendTranscript(session.id, text).catch((err) => { console.error(`[terminal] Failed to append transcript for session ${session.id}:`, err) }) } - if (ws.readyState !== 1) return; - ws.send(JSON.stringify({ type: 'terminal.output', dataBase64 })); }, onExit: () => { if (ws.readyState !== 1) return; diff --git a/backend/src/terminal/sidecar-manager.ts b/backend/src/terminal/sidecar-manager.ts index 6bbe066..1a00a7c 100644 --- a/backend/src/terminal/sidecar-manager.ts +++ b/backend/src/terminal/sidecar-manager.ts @@ -34,7 +34,8 @@ type SidecarResponse = { }; export interface SidecarStreamListener { - onOutput: (chunk: { text: string; dataBase64: string }) => void; + onData: (dataBase64: string) => void; + onText: (text: string) => void; onExit: (exitCode: number) => void; onError: (message: string) => void; } @@ -180,20 +181,29 @@ class SidecarManager { break; case 'output': if (message.data) { - const decoded = record.decoder.write(Buffer.from(message.data, 'base64')); - if (decoded) { - record.listener.onOutput({ text: decoded, dataBase64: message.data }); + // 1. Always send raw data immediately to ensure live terminal is bit-perfect + record.listener.onData(message.data); + + // 2. Process text for transcripts (handling UTF-8 boundaries) + try { + const decoded = record.decoder.write(Buffer.from(message.data, 'base64')); + if (decoded) { + record.listener.onText(decoded); + } + } catch (err) { + console.error(`[sidecar] PTY Decode Error for stream ${streamId}:`, err); } } break; case 'exit': { - const trailing = record.decoder.end(); - if (trailing) { - record.listener.onOutput({ - text: trailing, - dataBase64: Buffer.from(trailing, 'utf8').toString('base64'), - }); + try { + const trailing = record.decoder.end(); + if (trailing) { + record.listener.onText(trailing); + } + } catch (err) { + console.error(`[sidecar] PTY Flush Error on exit for stream ${streamId}:`, err); } } record.listener.onExit(message.exitCode ?? 0); @@ -201,12 +211,13 @@ class SidecarManager { break; case 'error': { { - const trailing = record.decoder.end(); - if (trailing) { - record.listener.onOutput({ - text: trailing, - dataBase64: Buffer.from(trailing, 'utf8').toString('base64'), - }); + try { + const trailing = record.decoder.end(); + if (trailing) { + record.listener.onText(trailing); + } + } catch (err) { + console.error(`[sidecar] PTY Flush Error on error for stream ${streamId}:`, err); } } const error = new Error(message.message ?? 'PTY sidecar error'); diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 353ca26..0fa7245 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -15,7 +15,7 @@ Standard mobile keyboards are missing critical developer keys (`Ctrl`, `Esc`, `T * **Custom Keybar**: CloudCode provides a thumb-friendly bar for these modifiers. * **Control Mode**: Tapping `CTRL` opens a specialized overlay grid, making complex shortcuts like `Ctrl+C` or `Ctrl+Z` effortless on a touchscreen. * **Haptic Feedback**: Every keypress provides a subtle vibration, making the virtual terminal feel tactile and responsive. -* **Live PTY Stream**: CloudCode uses a dedicated PTY sidecar to attach to tmux and stream raw terminal bytes to the browser, preserving interactive terminal behavior with scrollback and fewer rendering artifacts. +* **Bit-Perfect PTY Stream**: CloudCode uses a dedicated PTY sidecar to attach to tmux. The stream is decoupled: raw binary bytes are forwarded immediately to the browser for a bit-perfect live view, while a parallel processor handles UTF-8 decoding and semantic filtering for the transcript. This ensures no characters (like emojis or symbols) are dropped or corrupted. ### 3. Connection Resilience @@ -54,5 +54,5 @@ Because agents are powerful, CloudCode prioritizes transparency: ### Summary Flow 1. **Workstation**: Runs the CloudCode backend, SQLite DB, and tmux. 2. **Tailscale**: Securely tunnels your phone to your workstation. -3. **Phone**: Accesses the CloudCode PWA to launch, monitor, and interact with agents via a live PTY stream backed by tmux sessions. +3. **Phone / Terminal**: Accesses the CloudCode PWA or uses the `cloudcode` CLI to launch, monitor, and interact with agents via a bit-perfect PTY stream backed by tmux sessions. 4. **Resilience layer**: Server heartbeat + client watchdog + network-event listener ensure the WebSocket reconnects within seconds of any network disruption β€” phone sleep, Wi-Fi↔cellular switch, or brief signal loss. diff --git a/docs/install.md b/docs/install.md index f97d929..2d71264 100644 --- a/docs/install.md +++ b/docs/install.md @@ -22,10 +22,10 @@ npm install -g @humans-of-ai/cloudcode ``` -Verify the install: +Verify the install and check your system for dependencies: ```bash -cloudcode --version +cloudcode init ``` Then run first-time setup: