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

## [Unreleased]

### Added
- **Agent Reflection (`cloudcode summary`)**: A new CLI command that pipes the semantic transcript of a past session into a local AI agent (like Claude or Gemini). It automatically spins up a hidden tmux session, injects the transcript as context, and prompts the agent to summarize the architectural decisions and files changed—perfect for generating PR descriptions from your mobile pairing sessions.

## [0.1.9] — 2026-03-27

### Added
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ 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 summary <id>`**: Automatically spin up a local agent to read the transcript and summarize the session for you (great for PR descriptions!).
- **`cloudcode stop <id>`**: Gracefully stop an active agent session.
- **`cloudcode init`**: Verify your environment (Node, Go, tmux, git) and detect installed agents.

Expand Down
113 changes: 113 additions & 0 deletions backend/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { join } from 'path';
import { tmpdir, networkInterfaces } from 'os';
import { randomBytes } from 'crypto';
import { nanoid } from 'nanoid';
import { writeFileSync, chmodSync } from 'fs';
import { buildApp } from './index.js';
import { runMigrations } from './db/migrations.js';
import { getFirstAdminUser, createPairingToken, hashPassword, createUser } from './auth/service.js';
Expand Down Expand Up @@ -261,6 +262,118 @@ program
}
});

program
.command('summary')
.description('Generate a summary of a session using a local AI agent')
.argument('<id>', 'Public ID of the session to summarize')
.option('--agent <slug>', 'Agent profile slug to use (e.g. claude-code)', 'claude-code')
.action(async (id: string, options: { agent: string }) => {
runMigrations();

// 1. Fetch Session
const session = db.prepare('SELECT id, title, workdir FROM sessions WHERE public_id = ?').get(id) as { id: string; title: string; workdir: string } | undefined;
if (!session) {
console.error(chalk.red(`Error: Session "${id}" not found.`));
process.exit(1);
}

// 2. Fetch Agent
const profile = db.prepare('SELECT * FROM agent_profiles WHERE slug = ?').get(options.agent) 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

Using as any bypasses TypeScript's type safety. It's better to define a type for the data you expect from the database to catch potential errors and improve code clarity.

For example:

type AgentProfileFromDb = {
  name: string;
  command: string;
  args_json: string;
  env_json: string;
  // ... other fields you use
};

const profile = db.prepare('SELECT * FROM agent_profiles WHERE slug = ?').get(options.agent) as AgentProfileFromDb | undefined;

if (!profile) {
console.error(chalk.red(`Error: Agent profile "${options.agent}" not found.`));
process.exit(1);
}

console.log(chalk.blue(`\n📝 Generating summary for: ${session.title} [${id}]`));
console.log(chalk.dim(`Using agent: ${profile.name}`));

// 3. Get Transcript
let transcript = '';
try {
transcript = await readTranscript(session.id, { asMarkdown: true });
} catch (err) {
console.error(chalk.red('Failed to read transcript.'), err);
process.exit(1);
}

if (!transcript || transcript.trim().length === 0) {
console.log(chalk.yellow('Transcript is empty. Nothing to summarize.'));
process.exit(0);
}

// 4. Create Temp File
const tmpPromptPath = join(tmpdir(), `cc-summary-${id}-${Date.now()}.md`);
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

Using Date.now() for temporary filenames can lead to collisions if the command is executed multiple times within the same millisecond. Since nanoid is already a dependency, using it would generate a more robust and unique filename.

Suggested change
const tmpPromptPath = join(tmpdir(), `cc-summary-${id}-${Date.now()}.md`);
const tmpPromptPath = join(tmpdir(), `cc-summary-${id}-${nanoid(8)}.md`);

const prompt = `Summarize the following coding session transcript. Focus on:
1. The primary goal or issue being addressed.
2. The architectural decisions made.
3. The specific files changed.
4. The final outcome or status.

--- TRANSCRIPT ---
${transcript}
`;

try {
writeFileSync(tmpPromptPath, prompt, 'utf8');
chmodSync(tmpPromptPath, 0o600); // Only owner can read/write
} catch (err) {
console.error(chalk.red('Failed to write temporary prompt file.'), err);
process.exit(1);
}

// 5. Execution Strategy
const tmuxSessionName = `cc-summary-${nanoid(6)}`;
const args = JSON.parse(profile.args_json) as string[];
const env = JSON.parse(profile.env_json) as Record<string, string>;

try {
// Import tmux adapter dynamically or if already imported, use it
const tmux = await import('./tmux/adapter.js');

await tmux.createSession(
tmuxSessionName,
profile.command,
args,
session.workdir || process.cwd(),
Object.keys(env).length > 0 ? env : undefined
);

// Wait a moment for the agent to boot up
await new Promise(resolve => setTimeout(resolve, 1500));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Using a fixed-duration setTimeout to wait for the agent to boot is brittle. The agent might take longer to start under certain conditions, leading to race conditions where the next command is sent too early. A more robust approach is to poll the tmux pane until a prompt or some initial output appears, indicating the agent is ready. This ensures the script waits for as long as necessary, but no longer.


// Instruct the agent to read the file
// Different agents have different ways to read files.
// For Claude Code and Gemini CLI, usually just asking them to read the absolute path works.
const readCommand = `Please read the file at ${tmpPromptPath} and provide the summary.`;

await tmux.sendLiteralText(tmuxSessionName, readCommand);
await tmux.sendEnter(tmuxSessionName);

console.log(chalk.green(`\n✅ Summary agent launched.`));
console.log(chalk.yellow(`Attaching to session... (Type /exit, Ctrl-D, or Ctrl-b d to leave when finished)\n`));

// Attach to show output
const child = spawn('tmux', ['attach-session', '-t', tmuxSessionName], {
stdio: 'inherit'
});

child.on('exit', () => {
console.log(chalk.gray(`\nDetached from summary session.`));
// Cleanup temp file
try {
// fs.unlinkSync(tmpPromptPath);
// Leaving it might be useful if they want to see what was sent, but better to clean up.
import('fs').then(fs => fs.unlinkSync(tmpPromptPath)).catch(() => {});
} catch {}
Comment on lines +363 to +367
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The temporary file cleanup is unreliable. The exit event handler is synchronous, but import('fs').then(...) is asynchronous. This creates a race condition where the process may exit before the file is deleted.

To ensure cleanup happens, you should use the synchronous unlinkSync function.

First, add unlinkSync to your import statement at the top of the file (line 12):

import { writeFileSync, chmodSync, unlinkSync } from 'fs';

Then, replace this block with a simple synchronous call:

Suggested change
try {
// fs.unlinkSync(tmpPromptPath);
// Leaving it might be useful if they want to see what was sent, but better to clean up.
import('fs').then(fs => fs.unlinkSync(tmpPromptPath)).catch(() => {});
} catch {}
try {
const fs = require('fs');
fs.unlinkSync(tmpPromptPath);
} catch {
// Best-effort cleanup, ignore errors.
}

process.exit(0);
});

} catch (err) {
console.error(chalk.red('Failed to run summary session:'), err);
process.exit(1);
}
});
Comment on lines +270 to +375
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

This action handler is over 100 lines long and handles multiple distinct steps (fetching data, creating files, running tmux, etc.). For better readability, maintainability, and testability, consider refactoring it into smaller, single-purpose helper functions.

For example, you could have functions like:

  • getSessionAndProfile(id, agentSlug)
  • createSummaryPromptFile(transcript)
  • launchSummaryAgent(profile, promptPath, workdir)


program
.command('start')
.description('Start the CloudCode server')
Expand Down
36 changes: 18 additions & 18 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading