diff --git a/CLAUDE.md b/CLAUDE.md
index 6f5702bc5..42206b44e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,15 +4,25 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
-Voice-controlled terminal assistant using OpenAI's Realtime API (via Whisper STT, GPT-4, and TTS). Backend server with Expo app for all platforms (iOS, Android, and Web).
+Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket.
+
+**Key features:**
+- Real-time streaming of agent output
+- Voice commands for hands-free interaction
+- Push notifications when tasks complete
+- Multi-agent orchestration across projects
+
+**Not a cloud sandbox** - Paseo connects directly to your actual development environment. Your code stays on your machine.
+
+**Supported agents:** Claude Code, Codex, and OpenCode.
## Monorepo Structure
This is an npm workspace monorepo:
-- **Root**: Workspace configuration and shared TypeScript config
-- **packages/server**: Backend server (Express + WebSocket API)
-- **packages/app**: Cross-platform app (Expo - iOS, Android, Web)
+- **packages/server**: The Paseo daemon that runs on your machine. Manages agent processes, provides WebSocket API for real-time streaming, and exposes an MCP server for agent control.
+- **packages/app**: Cross-platform client (Expo). Connects to one or more servers, displays agent output, handles voice input, and sends push notifications.
+- **packages/website**: Marketing site at paseo.dev (TanStack Router + Cloudflare Workers).
## Environment overrides
diff --git a/packages/app/app.config.js b/packages/app/app.config.js
index a202e9746..1f538a1e4 100644
--- a/packages/app/app.config.js
+++ b/packages/app/app.config.js
@@ -47,7 +47,7 @@ export default {
package: "com.moboudra.paseo",
},
web: {
- output: "static",
+ output: "single",
favicon: "./assets/images/favicon.png",
},
plugins: [
@@ -64,6 +64,13 @@ export default {
},
},
],
+ [
+ "expo-notifications",
+ {
+ icon: "./assets/images/notification-icon.png",
+ color: "#20744A",
+ },
+ ],
"expo-audio",
[
"expo-build-properties",
diff --git a/packages/app/assets/images/butterfly-green.svg b/packages/app/assets/images/butterfly-green.svg
new file mode 100644
index 000000000..abb3e8734
--- /dev/null
+++ b/packages/app/assets/images/butterfly-green.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/app/assets/images/butterfly-white.svg b/packages/app/assets/images/butterfly-white.svg
new file mode 100644
index 000000000..091bab8a5
--- /dev/null
+++ b/packages/app/assets/images/butterfly-white.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/app/assets/images/favicon-dark-attention.svg b/packages/app/assets/images/favicon-dark-attention.svg
new file mode 100644
index 000000000..04da62667
--- /dev/null
+++ b/packages/app/assets/images/favicon-dark-attention.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/app/assets/images/favicon-dark-running.svg b/packages/app/assets/images/favicon-dark-running.svg
new file mode 100644
index 000000000..3257fb17f
--- /dev/null
+++ b/packages/app/assets/images/favicon-dark-running.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/app/assets/images/favicon-dark.svg b/packages/app/assets/images/favicon-dark.svg
new file mode 100644
index 000000000..d1b4b5785
--- /dev/null
+++ b/packages/app/assets/images/favicon-dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/app/assets/images/favicon-light-attention.svg b/packages/app/assets/images/favicon-light-attention.svg
new file mode 100644
index 000000000..548b02252
--- /dev/null
+++ b/packages/app/assets/images/favicon-light-attention.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/app/assets/images/favicon-light-running.svg b/packages/app/assets/images/favicon-light-running.svg
new file mode 100644
index 000000000..934e79274
--- /dev/null
+++ b/packages/app/assets/images/favicon-light-running.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/app/assets/images/favicon-light.svg b/packages/app/assets/images/favicon-light.svg
new file mode 100644
index 000000000..c722dabc6
--- /dev/null
+++ b/packages/app/assets/images/favicon-light.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx
index d86bd96c0..f94405100 100644
--- a/packages/app/src/components/agent-list.tsx
+++ b/packages/app/src/components/agent-list.tsx
@@ -94,7 +94,7 @@ export function AgentList({
const isRunning = agent.status === "running";
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
- const statusColor = isRunning ? theme.colors.accent : agent.requiresAttention ? theme.colors.success : null;
+ const statusColor = isRunning ? theme.colors.palette.blue[500] : agent.requiresAttention ? theme.colors.success : null;
return (
-{file.deletions}
- {isExpanded && (
+ {isExpanded && (file.status === "too_large" || file.status === "binary") ? (
+
+
+ {file.status === "binary" ? "Binary file" : "Diff too large to display"}
+
+
+ ) : isExpanded ? (
- )}
+ ) : null}
);
});
@@ -629,4 +635,16 @@ const styles = StyleSheet.create((theme) => ({
contextLineText: {
color: theme.colors.foregroundMuted,
},
+ statusMessageContainer: {
+ borderTopWidth: theme.borderWidth[1],
+ borderTopColor: theme.colors.border,
+ backgroundColor: theme.colors.surface1,
+ paddingHorizontal: theme.spacing[3],
+ paddingVertical: theme.spacing[4],
+ },
+ statusMessageText: {
+ fontSize: theme.fontSize.sm,
+ color: theme.colors.foregroundMuted,
+ fontStyle: "italic",
+ },
}));
diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts
index dfc72bd08..38d34858a 100644
--- a/packages/server/src/server/agent/provider-registry.ts
+++ b/packages/server/src/server/agent/provider-registry.ts
@@ -7,7 +7,7 @@ import type {
import { ClaudeAgentClient } from "./providers/claude-agent.js";
import { CodexMcpAgentClient } from "./providers/codex-mcp-agent.js";
-import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
+import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
import {
AGENT_PROVIDER_DEFINITIONS,
@@ -78,3 +78,7 @@ export async function fetchProviderModels(
const definition = getProviderDefinition(provider);
return definition.fetchModels(options);
}
+
+export async function shutdownProviders(): Promise {
+ await OpenCodeServerManager.getInstance().shutdown();
+}
diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts
index 0954ccb93..ad4931677 100644
--- a/packages/server/src/server/agent/providers/opencode-agent.ts
+++ b/packages/server/src/server/agent/providers/opencode-agent.ts
@@ -62,6 +62,7 @@ async function findAvailablePort(): Promise {
export class OpenCodeServerManager {
private static instance: OpenCodeServerManager | null = null;
+ private static exitHandlerRegistered = false;
private server: ChildProcess | null = null;
private port: number | null = null;
private startPromise: Promise<{ port: number; url: string }> | null = null;
@@ -69,10 +70,29 @@ export class OpenCodeServerManager {
static getInstance(): OpenCodeServerManager {
if (!OpenCodeServerManager.instance) {
OpenCodeServerManager.instance = new OpenCodeServerManager();
+ OpenCodeServerManager.registerExitHandler();
}
return OpenCodeServerManager.instance;
}
+ private static registerExitHandler(): void {
+ if (OpenCodeServerManager.exitHandlerRegistered) {
+ return;
+ }
+ OpenCodeServerManager.exitHandlerRegistered = true;
+
+ const cleanup = () => {
+ const instance = OpenCodeServerManager.instance;
+ if (instance?.server && !instance.server.killed) {
+ instance.server.kill("SIGTERM");
+ }
+ };
+
+ process.on("exit", cleanup);
+ process.on("SIGTERM", cleanup);
+ process.on("SIGINT", cleanup);
+ }
+
async ensureRunning(): Promise<{ port: number; url: string }> {
if (this.startPromise) {
return this.startPromise;
diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts
index cfd337160..e869b694b 100644
--- a/packages/server/src/server/bootstrap.ts
+++ b/packages/server/src/server/bootstrap.ts
@@ -47,7 +47,7 @@ import { AgentRegistry } from "./agent/agent-registry.js";
import { initializeTitleGenerator } from "../services/agent-title-generator.js";
import { attachAgentRegistryPersistence } from "./persistence-hooks.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
-import { createAllClients } from "./agent/provider-registry.js";
+import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
import type {
AgentClient,
AgentControlMcpConfig,
@@ -444,6 +444,7 @@ export async function createPaseoDaemon(
const stop = async () => {
await closeAllAgents(agentManager);
+ await shutdownProviders();
await wsServer.close();
await new Promise((resolve) => {
httpServer.close(() => resolve());
diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts
index 4da6e607a..3e1dbe298 100644
--- a/packages/server/src/server/config.ts
+++ b/packages/server/src/server/config.ts
@@ -79,10 +79,14 @@ export function loadConfig(
? Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")
: undefined;
+ const envCorsOrigins = env.PASEO_CORS_ORIGINS
+ ? env.PASEO_CORS_ORIGINS.split(",").map((s) => s.trim())
+ : [];
+
return {
listen,
paseoHome,
- corsAllowedOrigins: persisted.cors.allowedOrigins,
+ corsAllowedOrigins: [...persisted.cors.allowedOrigins, ...envCorsOrigins],
agentMcpRoute: DEFAULT_AGENT_MCP_ROUTE,
agentMcpAllowedHosts: [mcpListen, `localhost:${mcpListen.split(":")[1]}`],
auth: {
diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts
index fad4b5134..06d5ad2c5 100644
--- a/packages/server/src/server/session.ts
+++ b/packages/server/src/server/session.ts
@@ -20,7 +20,7 @@ import {
type FileDownloadTokenRequest,
type GitSetupOptions,
} from "./messages.js";
-import { parseAndHighlightDiff } from "./utils/diff-highlighter.js";
+import { parseAndHighlightDiff, type ParsedDiffFile } from "./utils/diff-highlighter.js";
import { getSystemPrompt } from "./agent/system-prompt.js";
import { getAllTools } from "./agent/llm-openai.js";
import { TTSManager } from "./agent/tts-manager.js";
@@ -2102,6 +2102,9 @@ export class Session {
`[Session ${this.clientId}] Handling highlighted diff request for agent ${agentId}`
);
+ // Maximum lines changed before we skip showing the diff content
+ const MAX_DIFF_LINES = 5000;
+
try {
const agents = this.agentManager.listAgents();
const agent = agents.find((a) => a.id === agentId);
@@ -2119,57 +2122,161 @@ export class Session {
return;
}
- // Get diff for tracked files
- const { stdout: trackedDiff } = await execAsync("git diff HEAD", {
- cwd: agent.cwd,
- });
+ // Step 1: Get the list of changed files with their stats (numstat gives additions/deletions per file)
+ const { stdout: numstatOutput } = await execAsync(
+ "git diff --numstat HEAD",
+ { cwd: agent.cwd }
+ );
- // Get diff for untracked files (new files not yet added to git)
- let untrackedDiff = "";
+ // Parse numstat output: "additions\tdeletions\tfilepath" or "-\t-\tfilepath" for binary
+ interface FileStats {
+ path: string;
+ additions: number;
+ deletions: number;
+ isBinary: boolean;
+ isTracked: boolean;
+ }
+ const fileStats: FileStats[] = [];
+
+ for (const line of numstatOutput.trim().split("\n").filter(Boolean)) {
+ const parts = line.split("\t");
+ if (parts.length >= 3) {
+ const [addStr, delStr, ...pathParts] = parts;
+ const path = pathParts.join("\t"); // Handle paths with tabs
+ const isBinary = addStr === "-" && delStr === "-";
+ fileStats.push({
+ path,
+ additions: isBinary ? 0 : parseInt(addStr, 10),
+ deletions: isBinary ? 0 : parseInt(delStr, 10),
+ isBinary,
+ isTracked: true,
+ });
+ }
+ }
+
+ // Step 2: Get untracked files
try {
const { stdout: untrackedFiles } = await execAsync(
"git ls-files --others --exclude-standard",
{ cwd: agent.cwd }
);
- const newFiles = untrackedFiles.trim().split("\n").filter(Boolean);
-
- for (const file of newFiles) {
+ for (const filePath of untrackedFiles.trim().split("\n").filter(Boolean)) {
+ // Use git's numstat with --no-index to detect binary files (cross-platform)
+ // Binary files show as "-\t-\tfilepath", text files show line counts
try {
- const { stdout: fileDiff } = await execAsync(
- `git diff --no-index /dev/null "${file}" || true`,
+ const { stdout: numstatLine } = await execAsync(
+ `git diff --numstat --no-index /dev/null "${filePath}" || true`,
{ cwd: agent.cwd }
);
- if (fileDiff) {
- untrackedDiff += fileDiff;
- }
+ const parts = numstatLine.trim().split("\t");
+ const isBinary = parts[0] === "-" && parts[1] === "-";
+ const additions = isBinary ? 0 : (parseInt(parts[0], 10) || 0);
+
+ fileStats.push({
+ path: filePath,
+ additions,
+ deletions: 0,
+ isBinary,
+ isTracked: false,
+ });
} catch {
- // Ignore errors for individual files
+ // If we can't determine, assume text and try to get it
+ fileStats.push({
+ path: filePath,
+ additions: 0,
+ deletions: 0,
+ isBinary: false,
+ isTracked: false,
+ });
}
}
} catch {
// Ignore errors getting untracked files
}
- const combinedDiff = trackedDiff + untrackedDiff;
+ // Step 3: Fetch diffs per-file, respecting limits
+ const allFiles: ParsedDiffFile[] = [];
- // Parse and highlight the diff
- const highlightedFiles = await parseAndHighlightDiff(
- combinedDiff,
- agent.cwd
- );
+ for (const stats of fileStats) {
+ const totalLines = stats.additions + stats.deletions;
+
+ // Handle binary files
+ if (stats.isBinary) {
+ allFiles.push({
+ path: stats.path,
+ isNew: !stats.isTracked,
+ isDeleted: false,
+ additions: 0,
+ deletions: 0,
+ hunks: [],
+ status: "binary",
+ });
+ continue;
+ }
+
+ // Handle files that are too large
+ if (totalLines > MAX_DIFF_LINES) {
+ allFiles.push({
+ path: stats.path,
+ isNew: !stats.isTracked,
+ isDeleted: false,
+ additions: stats.additions,
+ deletions: stats.deletions,
+ hunks: [],
+ status: "too_large",
+ });
+ continue;
+ }
+
+ // Fetch the actual diff for this file
+ try {
+ let fileDiff: string;
+ if (stats.isTracked) {
+ const { stdout } = await execAsync(
+ `git diff HEAD -- "${stats.path}"`,
+ { cwd: agent.cwd }
+ );
+ fileDiff = stdout;
+ } else {
+ const { stdout } = await execAsync(
+ `git diff --no-index /dev/null "${stats.path}" || true`,
+ { cwd: agent.cwd }
+ );
+ fileDiff = stdout;
+ }
+
+ if (fileDiff) {
+ const parsedFiles = await parseAndHighlightDiff(fileDiff, agent.cwd);
+ for (const file of parsedFiles) {
+ allFiles.push({ ...file, status: "ok" });
+ }
+ }
+ } catch {
+ // If diff fails for this file, add it with empty hunks
+ allFiles.push({
+ path: stats.path,
+ isNew: !stats.isTracked,
+ isDeleted: false,
+ additions: stats.additions,
+ deletions: stats.deletions,
+ hunks: [],
+ status: "ok",
+ });
+ }
+ }
this.emit({
type: "highlighted_diff_response",
payload: {
agentId,
- files: highlightedFiles,
+ files: allFiles,
error: null,
requestId,
},
});
console.log(
- `[Session ${this.clientId}] Highlighted diff for agent ${agentId} completed (${highlightedFiles.length} files)`
+ `[Session ${this.clientId}] Highlighted diff for agent ${agentId} completed (${allFiles.length} files)`
);
} catch (error: any) {
console.error(
diff --git a/packages/server/src/server/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts
index 82e5649e6..8ccbc57fe 100644
--- a/packages/server/src/server/utils/diff-highlighter.ts
+++ b/packages/server/src/server/utils/diff-highlighter.ts
@@ -27,6 +27,7 @@ export interface ParsedDiffFile {
additions: number;
deletions: number;
hunks: DiffHunk[];
+ status?: "ok" | "too_large" | "binary";
}
/**
diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts
index bf9199571..386df5374 100644
--- a/packages/server/src/shared/messages.ts
+++ b/packages/server/src/shared/messages.ts
@@ -461,6 +461,7 @@ const ParsedDiffFileSchema = z.object({
additions: z.number(),
deletions: z.number(),
hunks: z.array(DiffHunkSchema),
+ status: z.enum(["ok", "too_large", "binary"]).optional(),
});
export const HighlightedDiffRequestSchema = z.object({