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 @@ -6,6 +6,29 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

---

## [Unreleased]

### Added

- Mobile task dispatcher on the dashboard for fast task submission to local coding agents
- Visible agent selection in the quick dispatch card so tasks can be routed to different agents intentionally
- Full transcript reader with paginated history, scrollback loading, and timestamped session output
- Session loading panel that appears immediately after launch so users see progress before terminal output arrives

### Changed

- Dashboard layout now prioritizes live sessions and reduces top-level copy for a cleaner mobile control surface
- Quick dispatch now opens the live terminal first, while logs remain a secondary transcript view
- Session details are tucked behind a `Details` action instead of competing with the primary live views
- Mirror/live session UX now reflects CloudCode-managed sessions and uses `Live Sessions` terminology

### Fixed

- Transcript capture now starts at session creation time and avoids duplicate writes from websocket fallback paths
- Repeated terminal redraw noise is deduplicated before it reaches the transcript reader
- Logs now start from the beginning of a session by default instead of only showing the current terminal screen
- Background transcript append failures are logged with session context instead of being swallowed silently

## [0.1.6] — 2026-03-18

### Fixed
Expand Down
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
106 changes: 56 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,64 @@ 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((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 +350,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 +426,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 +443,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