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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

## [0.1.11] — 2026-04-08

### Added

- **Smart Approval Modals**: Automatically detects `[y/n]` and "Press Enter" prompts in any agent session. A native card slides up from the bottom of the screen with large, tap-friendly Approve and Deny buttons — no keyboard required.
- **Action Timeline**: A new Timeline tab intercepts agent tool-use events (Claude tool boxes, generic shell commands) and renders them as collapsible cards with status indicators, timestamps, and tool type icons. Provides a high-signal activity feed easy to skim on mobile.
- **Heuristics Engine**: A semantic PTY parser (`HeuristicsEngine`) runs a headless xterm instance per session to detect prompt boundaries and tool-use events with high precision. Agent-agnostic — works with Claude Code, Gemini CLI, and any CLI tool using standard terminal conventions.

### Improved

- **Serial chunk processing**: Heuristics engine now queues PTY chunks through a serial promise chain, eliminating potential race conditions when high-frequency output arrives faster than xterm can process it.
- **Resource cleanup**: Headless xterm instances are disposed on both WebSocket `close` and `error` events, preventing memory leaks in long-running or dropped sessions.
- **Timeline memory cap**: Action timeline is capped at 100 entries per session to bound memory growth during extended agent runs.

### Fixed

- **Prompt self-dismiss**: Approval modal closes immediately on tap without waiting for the next backend event, eliminating UI flicker on slow connections.

## [0.1.10] — 2026-03-28

### Fixed
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ 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.
- **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.
- **Smart Remote Control:** Detects agent activity and transforms it into a **Task Timeline** of collapsible cards.
- **Instant Approvals:** Automatically pops up native **Approval Modals** when an agent requests permission, saving you from opening the mobile keyboard.
- **Task launch:** Start tasks instantly from your mobile dashboard without opening a full terminal.
- **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.
Expand Down
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@humans-of-ai/cloudcode",
"version": "0.1.10",
"version": "0.1.11",
"description": "CloudCode — Remote control for your local AI coding agents",
"license": "MIT",
"type": "module",
Expand Down
230 changes: 230 additions & 0 deletions backend/src/terminal/heuristics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { HeuristicsEngine } from './heuristics.js';

function encode(text: string): string {
return Buffer.from(text).toString('base64');
}

describe('HeuristicsEngine', () => {
let engine: HeuristicsEngine;

beforeEach(() => {
engine = new HeuristicsEngine();
});

afterEach(() => {
engine.dispose();
});

describe('process', () => {
it('returns empty result for empty chunk', async () => {
const result = await engine.process('');
expect(result).toEqual({});
});

it('handles null/undefined chunk gracefully', async () => {
// @ts-ignore - testing invalid input
const result = await engine.process(null);
expect(result).toEqual({});
});

it('processes chunk without throwing', async () => {
await expect(engine.process(encode('some terminal output'))).resolves.not.toThrow();
});
});

describe('prompt detection', () => {
it('detects [Y/n] yesno prompt and extracts context', async () => {
const result = await engine.process(encode('Overwrite file.txt? [Y/n]'));
expect(result.prompt?.isActive).toBe(true);
expect(result.prompt?.type).toBe('yesno');
expect(result.prompt?.text).toContain('Overwrite file.txt');
});

it('detects (y/N) yesno prompt', async () => {
const result = await engine.process(encode('Do you want to continue? (y/N)'));
expect(result.prompt?.isActive).toBe(true);
expect(result.prompt?.type).toBe('yesno');
});

it('detects "Press Enter to continue" prompt', async () => {
const result = await engine.process(encode('Changes applied.\nPress Enter to continue...'));
expect(result.prompt?.isActive).toBe(true);
expect(result.prompt?.type).toBe('enter');
});

it('detects "Press any key to exit" prompt', async () => {
const result = await engine.process(encode('Press any key to exit'));
expect(result.prompt?.isActive).toBe(true);
expect(result.prompt?.type).toBe('enter');
});

it('does not re-emit unchanged prompt state', async () => {
const result1 = await engine.process(encode('Delete this? [Y/n]'));
expect(result1.prompt?.isActive).toBe(true);
// Empty chunk: state unchanged, should not re-emit
const result2 = await engine.process(encode(''));
expect(result2.prompt).toBeUndefined();
});
});

describe('action detection — Claude tool-use boxes', () => {
it('detects a Bash tool-use start and returns a running action', async () => {
const toolBox = '┌─ Tool Use: Bash ────────────────────────┐\n│ npm test │\n';
const result = await engine.process(encode(toolBox));
expect(result.action).toBeDefined();
expect(result.action?.type).toBe('bash');
expect(result.action?.status).toBe('running');
expect(result.action?.label).toContain('npm test');
});

it('detects a Read tool-use start', async () => {
const toolBox = '┌─ Tool Use: Read ────────────────────────┐\n│ src/index.ts │\n';
const result = await engine.process(encode(toolBox));
expect(result.action?.type).toBe('read');
expect(result.action?.status).toBe('running');
});

it('detects an Edit tool-use start', async () => {
const toolBox = '┌─ Tool Use: Edit ────────────────────────┐\n│ package.json │\n';
const result = await engine.process(encode(toolBox));
expect(result.action?.type).toBe('edit');
expect(result.action?.status).toBe('running');
});

it('detects tool completion and returns completed status', async () => {
const toolStart = '┌─ Tool Use: Bash ────────────────────────┐\n│ ls -la │\n';
await engine.process(encode(toolStart));
const toolEnd = 'total 42\ndrwxr-xr-x 8 user group 256 Jan 1 00:00 .\n└──────────────────────────────────────────┘\n';
const result = await engine.process(encode(toolEnd));
expect(result.action?.status).toBe('completed');
});

it('does not create duplicate running actions for the same tool call', async () => {
const toolBox = '┌─ Tool Use: Bash ────────────────────────┐\n│ git status │\n';
const result1 = await engine.process(encode(toolBox));
expect(result1.action?.status).toBe('running');
// Empty chunk, same buffer state: should not emit a new action
const result2 = await engine.process(encode(''));
expect(result2.action).toBeUndefined();
});

it('assigns a unique id to each distinct action', async () => {
// Start and complete the first action
const tool1Start = '┌─ Tool Use: Bash ────────────────────────┐\n│ echo hello │\n';
const r1 = await engine.process(encode(tool1Start));
const id1 = r1.action?.id;
await engine.process(encode('hello\n└──────────────────────────────────────────┘\n'));

// Start a second action — must get a different id
const tool2Start = '┌─ Tool Use: Read ────────────────────────┐\n│ README.md │\n';
const r2 = await engine.process(encode(tool2Start));
const id2 = r2.action?.id;

expect(id1).toBeDefined();
expect(id2).toBeDefined();
expect(id1).not.toBe(id2);
});
});

describe('action detection — shell command fallback', () => {
it('detects a $ shell command', async () => {
const result = await engine.process(encode('$ git diff HEAD~1'));
expect(result.action?.type).toBe('bash');
expect(result.action?.label).toBe('git diff HEAD~1');
expect(result.action?.status).toBe('running');
});

it('detects a ❯ shell command', async () => {
const result = await engine.process(encode('❯ npm run build'));
expect(result.action?.type).toBe('bash');
expect(result.action?.label).toBe('npm run build');
});

it('ignores bare shell names that are not meaningful commands', async () => {
const result = await engine.process(encode('$ bash'));
expect(result.action).toBeUndefined();
});

it('ignores cd navigation commands', async () => {
const result = await engine.process(encode('$ cd /home/user'));
expect(result.action).toBeUndefined();
});
});

describe('dispose', () => {
it('cleans up resources without throwing', () => {
expect(() => engine.dispose()).not.toThrow();
});

it('can be called multiple times safely', () => {
expect(() => {
engine.dispose();
engine.dispose();
}).not.toThrow();
});

it('returns empty result after dispose', async () => {
engine.dispose();
const result = await engine.process(encode('test'));
expect(result).toEqual({});
});
});

describe('UTF-8 handling', () => {
it('handles multi-byte UTF-8 characters without throwing', async () => {
await expect(engine.process(encode('Hello 世界 🌍'))).resolves.not.toThrow();
});

it('handles incomplete UTF-8 sequences across chunk boundaries', async () => {
// '世界' is 6 bytes; split after byte 3 (mid-character)
const fullBytes = Buffer.from('世界');
const chunk1 = fullBytes.slice(0, 3).toString('base64');
const chunk2 = fullBytes.slice(3).toString('base64');
await engine.process(chunk1);
await expect(engine.process(chunk2)).resolves.not.toThrow();
});

it('handles emojis', async () => {
await expect(engine.process(encode('Status: ✅ Error: ❌'))).resolves.not.toThrow();
});
});

describe('resource management', () => {
it('maintains separate state for multiple instances', async () => {
const engine1 = new HeuristicsEngine();
const engine2 = new HeuristicsEngine();
await engine1.process(encode('test'));
await engine2.process(encode('test'));
expect(() => engine1.dispose()).not.toThrow();
expect(() => engine2.dispose()).not.toThrow();
});
});

describe('large output handling', () => {
it('handles large chunks without crashing', async () => {
await expect(engine.process(encode('x'.repeat(10000)))).resolves.not.toThrow();
});

it('handles multiple rapid chunks', async () => {
for (let i = 0; i < 10; i++) {
await expect(engine.process(encode(`line ${i}\n`))).resolves.not.toThrow();
}
});
});

describe('edge cases', () => {
it('handles binary data gracefully', async () => {
const binary = Buffer.from([0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD]);
await expect(engine.process(binary.toString('base64'))).resolves.not.toThrow();
});

it('handles ANSI escape sequences', async () => {
await expect(engine.process(encode('\x1b[32mGreen\x1b[0m \x07'))).resolves.not.toThrow();
});

it('handles whitespace-only content', async () => {
await expect(engine.process(encode(' \n\n \r\n '))).resolves.not.toThrow();
});
});
});
Loading
Loading