From aa090748a62e6e398deaefa9fc998bef3947147b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 14 Jan 2026 19:23:27 +0700 Subject: [PATCH] chore: sync all local changes --- CLAUDE.md | 18 +- packages/app/app.config.js | 9 +- .../app/assets/images/butterfly-green.svg | 3 + .../app/assets/images/butterfly-white.svg | 3 + .../assets/images/favicon-dark-attention.svg | 4 + .../assets/images/favicon-dark-running.svg | 4 + packages/app/assets/images/favicon-dark.svg | 3 + .../assets/images/favicon-light-attention.svg | 4 + .../assets/images/favicon-light-running.svg | 4 + packages/app/assets/images/favicon-light.svg | 3 + packages/app/src/components/agent-list.tsx | 2 +- packages/app/src/components/git-diff-pane.tsx | 22 ++- .../src/server/agent/provider-registry.ts | 6 +- .../server/agent/providers/opencode-agent.ts | 20 +++ packages/server/src/server/bootstrap.ts | 3 +- packages/server/src/server/config.ts | 6 +- packages/server/src/server/session.ts | 155 +++++++++++++++--- .../src/server/utils/diff-highlighter.ts | 1 + packages/server/src/shared/messages.ts | 1 + 19 files changed, 236 insertions(+), 35 deletions(-) create mode 100644 packages/app/assets/images/butterfly-green.svg create mode 100644 packages/app/assets/images/butterfly-white.svg create mode 100644 packages/app/assets/images/favicon-dark-attention.svg create mode 100644 packages/app/assets/images/favicon-dark-running.svg create mode 100644 packages/app/assets/images/favicon-dark.svg create mode 100644 packages/app/assets/images/favicon-light-attention.svg create mode 100644 packages/app/assets/images/favicon-light-running.svg create mode 100644 packages/app/assets/images/favicon-light.svg 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({