Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`: Instantly re-enter any session's native `tmux` environment.
- `cloudcode logs <id>`: Stream clean semantic Markdown logs to the terminal. Use `-f` to follow live.
- `cloudcode stop <id>`: 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
Expand Down
25 changes: 14 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

---
Expand All @@ -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 <id>`**: Directly attach to a session's native `tmux` environment.
- **`cloudcode logs <id>`**: Stream clean semantic transcripts (Markdown) of a session. Use `-f` to follow live.
- **`cloudcode stop <id>`**: 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
Expand Down
205 changes: 205 additions & 0 deletions backend/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 <agent>" 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[];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The type assertion as any[] can be made more specific to improve type safety and maintainability. Consider defining an interface that accurately reflects the structure of the objects returned by this SQL query, which includes all fields from the Session type and the profile_name alias.

Suggested change
`).all() as any[];
`).all() as (Session & { profile_name: string })[];


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('<id>', '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('<id>', '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('<id>', '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')
Expand Down
3 changes: 2 additions & 1 deletion backend/src/sessions/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
18 changes: 10 additions & 8 deletions backend/src/terminal/readable-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
8 changes: 5 additions & 3 deletions backend/src/terminal/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading