Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ Start an agent on your laptop, walk away, and check in from your phone or tablet
## Why CloudCode?

- **Agent agnostic:** Works with Claude Code, Gemini CLI, OpenAI Codex, GitHub Copilot CLI, or any CLI tool.
- **Readable logs:** Intercepts raw terminal output and renders it as formatted Markdown — easier to read on mobile.
- **Task launch:** Start tasks instantly from your mobile dashboard without opening a full terminal. CloudCode defaults to your recent projects and drops you into the live session view.
- **Transcript logs:** Shows the full session output in a scrollable, timestamped transcript view.
- **QR code pairing:** Scan a QR code from your terminal to authenticate your phone. No passwords or SSH keys.
- **Persistent sessions:** Sessions run inside `tmux`. Your agent keeps working if your laptop sleeps or your connection drops. Reconnect and pick up where you left off.
- **Flexible networking:** Works on local Wi-Fi, over Tailscale (private network), or via Cloudflare Tunnels (no port-forwarding needed).
Expand Down Expand Up @@ -114,6 +115,18 @@ cloudcode run claude-code --rc --tunnel
cloudcode run custom --command "npx some-ai-tool" --rc
```

### Send vs create

Use the dashboard `Send` box when you want the fastest path:
- It uses your recent agent and workspace defaults.
- It creates a background session automatically.
- It opens the live terminal first.

Use `Create` when you want full control:
- Pick the exact agent profile.
- Choose the workspace or worktree deliberately.
- Set a title and startup prompt before launch.

---

## Architecture
Expand Down Expand Up @@ -162,4 +175,3 @@ Built by Alex Chao (@alexchaomander). Find me on my socials!
· [LinkedIn](https://www.linkedin.com/in/alexchao56/)
· [YouTube](https://www.youtube.com/@alexchaomander)
· [Substack](https://alexchao.substack.com/)

2 changes: 2 additions & 0 deletions backend/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ vi.mock('./tmux/adapter.js', () => ({
hasSession: vi.fn().mockResolvedValue(true),
listSessions: vi.fn().mockResolvedValue([]),
capturePane: vi.fn().mockResolvedValue('test output'),
capturePaneHistory: vi.fn().mockResolvedValue('history output'),
setHistoryLimit: vi.fn().mockResolvedValue(undefined),
resizeWindow: vi.fn().mockResolvedValue(undefined),
}));

Expand Down
104 changes: 54 additions & 50 deletions backend/src/sessions/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import { execFileSync } from 'node:child_process';
import { db } from '../db/index.js';
import { logAudit } from '../audit/service.js';
import * as tmux from '../tmux/adapter.js';
import { deleteTranscript, initTranscript } from '../terminal/transcript-store.js';
import { sidecarManager, type SidecarStreamHandle } from '../terminal/sidecar-manager.js';
import { appendTranscript, deleteTranscript, initTranscript } from '../terminal/transcript-store.js';
import { validateWorkdir } from '../utils/paths.js';
import type { AgentProfile, Session } from '../db/schema.js';
import { hasPromptMarker } from './startup-ready.js';

const INITIAL_STARTUP_INPUT_DELAY_MS = parseInt(process.env.INITIAL_STARTUP_INPUT_DELAY_MS ?? '1600', 10);
const FOLLOWUP_STARTUP_INPUT_DELAY_MS = parseInt(process.env.FOLLOWUP_STARTUP_INPUT_DELAY_MS ?? '700', 10);
const STARTUP_READY_TIMEOUT_MS = parseInt(process.env.STARTUP_READY_TIMEOUT_MS ?? '12000', 10);
const STARTUP_READY_POLL_MS = parseInt(process.env.STARTUP_READY_POLL_MS ?? '250', 10);

Expand Down Expand Up @@ -46,68 +47,62 @@ async function sendStartupLine(sessionName: string, text: string): Promise<void>
await tmux.sendEnter(sessionName);
}

function getStartupReadyPatterns(profile: AgentProfile): string[] {
const slug = profile.slug.toLowerCase();
const transcriptRecorders = new Map<string, SidecarStreamHandle>();

if (slug === 'gemini-cli') {
return [
'type your message or @path/to/file',
'? for shortcuts',
'/model auto',
];
}
export function hasTranscriptRecorder(sessionId: string): boolean {
return transcriptRecorders.has(sessionId);
}

if (slug === 'claude-code') {
return [
'? for shortcuts',
'try "',
'shift+tab to accept edits',
];
}
async function startTranscriptRecorder(sessionId: string, sessionName: string): Promise<void> {
if (transcriptRecorders.has(sessionId)) return;

if (slug === 'openai-codex') {
return [
'? for shortcuts',
'type your message',
'shift+tab to accept edits',
];
}
const recorder = await sidecarManager.openStream(sessionName, 160, 48, {
onOutput: ({ text }) => {
void appendTranscript(sessionId, text).catch(() => {});

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

Silently swallowing errors from appendTranscript can make debugging difficult if transcript recording fails. While transcript recording is best-effort, logging the error would provide visibility into potential issues (e.g., disk space, permissions) without crashing the main process.

Suggested change
void appendTranscript(sessionId, text).catch(() => {});
void appendTranscript(sessionId, text).catch((err) => { console.error(`Failed to append transcript for session ${sessionId}:`, err); });

},
onExit: () => {
transcriptRecorders.delete(sessionId);
},
onError: () => {
transcriptRecorders.delete(sessionId);
},
});

if (slug === 'github-copilot-cli') {
return [
'type your message',
'? for shortcuts',
];
}
transcriptRecorders.set(sessionId, recorder);
}

return ['type your message', '? for shortcuts', '> '];
async function stopTranscriptRecorder(sessionId: string): Promise<void> {
const recorder = transcriptRecorders.get(sessionId);
if (!recorder) return;
transcriptRecorders.delete(sessionId);
await recorder.close().catch(() => {});
}

function hasPromptMarker(content: string, patterns: string[]): boolean {
const normalized = content.toLowerCase();
if (patterns.some((pattern) => normalized.includes(pattern.toLowerCase()))) {
return true;
async function backfillTranscriptSnapshot(sessionId: string, sessionName: string): Promise<void> {
if (typeof tmux.capturePaneHistory !== 'function' || typeof tmux.capturePane !== 'function') {
return;
}

const lines = normalized
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const [historyOutput, currentOutput] = await Promise.all([
tmux.capturePaneHistory(sessionName),
tmux.capturePane(sessionName),
]);

const snapshot = [historyOutput, currentOutput].filter(Boolean).join('\n').trim();
if (!snapshot) return;

const lastLine = lines.at(-1) ?? '';
return ['>', '❯', '$', '#'].includes(lastLine);
await appendTranscript(sessionId, snapshot);
}

async function waitForStartupReady(sessionName: string, profile: AgentProfile): Promise<void> {
async function waitForStartupReady(sessionName: string, _profile: AgentProfile): Promise<void> {
const deadline = Date.now() + STARTUP_READY_TIMEOUT_MS;
const patterns = getStartupReadyPatterns(profile);
let sawOutput = false;

while (Date.now() < deadline) {
const content = await tmux.capturePane(sessionName);
if (content.trim()) {
sawOutput = true;
if (hasPromptMarker(content, patterns)) {
if (hasPromptMarker(content)) {
return;
}
}
Expand Down Expand Up @@ -353,17 +348,23 @@ export async function createSession(params: CreateSessionParams): Promise<Sessio
Object.keys(env).length > 0 ? env : undefined
);

if (typeof tmux.setHistoryLimit === 'function') {
await tmux.setHistoryLimit(tmuxSessionName, 100000).catch(() => {});
}

await backfillTranscriptSnapshot(id, tmuxSessionName);
await startTranscriptRecorder(id, tmuxSessionName).catch((err) => {
// Transcript recording is best-effort; session creation should still succeed.
console.warn('Failed to start transcript recorder', err);
});

if (profile.startup_template) {
await waitForStartupReady(tmuxSessionName, profile);
await sendStartupLine(tmuxSessionName, profile.startup_template);
}

if (startupPrompt) {
if (profile.startup_template) {
await sleep(FOLLOWUP_STARTUP_INPUT_DELAY_MS);
} else {
await waitForStartupReady(tmuxSessionName, profile);
}
await waitForStartupReady(tmuxSessionName, profile);
await sendStartupLine(tmuxSessionName, startupPrompt);
}

Expand Down Expand Up @@ -423,6 +424,7 @@ export async function killSession(id: string, userId: string): Promise<void> {
if (!exited) {
throw new Error(`Failed to terminate tmux session: ${session.tmux_session_name}`);
}
await stopTranscriptRecorder(session.id);

const now = new Date().toISOString();
db.prepare(`UPDATE sessions SET status = 'stopped', stopped_at = ?, updated_at = ? WHERE id = ?`).run(now, now, id);
Expand All @@ -439,6 +441,8 @@ export async function deleteSession(id: string, userId: string): Promise<void> {
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as (Session & { worktree_path: string | null }) | undefined;
if (!session) throw new Error(`Session not found: ${id}`);

await stopTranscriptRecorder(session.id);

const exists = await tmux.hasSession(session.tmux_session_name);
if (exists) {
await tmux.killSession(session.tmux_session_name);
Expand Down
21 changes: 21 additions & 0 deletions backend/src/sessions/startup-ready.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { hasPromptMarker } from './startup-ready.js';

describe('hasPromptMarker', () => {
it('ignores help text that appears before the actual prompt', () => {
const content = [
'Welcome to Gemini CLI',
'type your message or @path/to/file',
'? for shortcuts',
'',
].join('\n');

expect(hasPromptMarker(content)).toBe(false);
});

it('detects a ready prompt on the last line', () => {
expect(hasPromptMarker('> ')).toBe(true);
expect(hasPromptMarker('❯ ')).toBe(true);
expect(hasPromptMarker('> Refactor the login flow')).toBe(true);
});
});
21 changes: 21 additions & 0 deletions backend/src/sessions/startup-ready.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export function hasPromptMarker(content: string): boolean {
const lines = content
.split('\n')
.map((line) => line.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '').trimEnd())
.filter(Boolean);

const lastLine = lines.at(-1)?.trim() ?? '';
if (!lastLine) {
return false;
}

if (/^[>❯$#]\s*$/.test(lastLine)) {
return true;
}

if (/^[>❯$#]\s+\S+/.test(lastLine)) {
return true;
}

return false;
}
11 changes: 5 additions & 6 deletions backend/src/terminal/mirror-routes.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import type { FastifyPluginAsync } from 'fastify';
import { listSessions } from '../tmux/adapter.js';
import { requireAuth } from '../auth/middleware.js';
import { listSessions } from '../sessions/service.js';

const mirrorRoutes: FastifyPluginAsync = async (fastify) => {
// GET /api/v1/terminal/tmux-sessions - list all active tmux sessions for mirroring
// GET /api/v1/terminal/tmux-sessions - list CloudCode-managed live sessions for mirroring
fastify.get('/api/v1/terminal/tmux-sessions', { preHandler: requireAuth }, async (request, reply) => {
try {
const sessions = await listSessions();
// Filter out CloudCode-managed sessions if we want a clean "Mirror" list,
// but showing all is more powerful for "Remote Control"
return reply.send(sessions);
const sessions = listSessions();
const filteredSessions = sessions.filter((session) => session.status === 'running' || session.status === 'starting');
return reply.send(filteredSessions);
} catch (err) {
fastify.log.error(err);
return reply.status(500).send({ error: 'Internal Server Error', message: 'Failed to list tmux sessions' });
Expand Down
29 changes: 29 additions & 0 deletions backend/src/terminal/readable-transcript.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { formatReadableTranscript } from './readable-transcript.js';

describe('formatReadableTranscript', () => {
it('preserves markdown structure and strips terminal chrome', () => {
const raw = [
'cc-12345/projects/demo',
'IMPLEMENTATION PLAN',
'- Update the login flow',
'- Add tests',
'',
'const answer = 42',
'console.log(answer)',
'',
'DONE',
].join('\n');

const formatted = formatReadableTranscript(raw);

expect(formatted).toContain('## IMPLEMENTATION PLAN');
expect(formatted).toContain('- Update the login flow');
expect(formatted).toContain('- Add tests');
expect(formatted).toContain('```text');
expect(formatted).toContain('const answer = 42');
expect(formatted).toContain('console.log(answer)');
expect(formatted).toContain('## DONE');
expect(formatted).not.toContain('cc-12345');
});
});
Loading
Loading