Improve daemon RPC resiliency and diff handling

This commit is contained in:
Mohamed Boudra
2026-02-04 10:21:36 +07:00
parent 10a2ef34a3
commit 79aec0069c
26 changed files with 467 additions and 109 deletions
@@ -45,7 +45,7 @@ import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-typ
import type { Agent } from "@/contexts/session-context"; import type { Agent } from "@/contexts/session-context";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; import type { DaemonClient } from "@server/client/daemon-client";
import { parseToolCallDisplay } from "@/utils/tool-call-parsers"; import { parseToolCallDisplay } from "@/utils/tool-call-parsers";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
import { ToolCallSheetProvider } from "./tool-call-sheet"; import { ToolCallSheetProvider } from "./tool-call-sheet";
@@ -760,7 +760,7 @@ function PermissionRequestCard({
client, client,
}: { }: {
permission: PendingPermission; permission: PendingPermission;
client: DaemonClientV2 | null; client: DaemonClient | null;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = const isMobile =
@@ -26,7 +26,7 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation"; import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "./dictation-controls"; import { DictationOverlay } from "./dictation-controls";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; import type { DaemonClient } from "@server/client/daemon-client";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useVoiceOptional } from "@/contexts/voice-context"; import { useVoiceOptional } from "@/contexts/voice-context";
@@ -51,7 +51,7 @@ export interface MessageInputProps {
images?: ImageAttachment[]; images?: ImageAttachment[];
onPickImages?: () => void; onPickImages?: () => void;
onRemoveImage?: (index: number) => void; onRemoveImage?: (index: number) => void;
client: DaemonClientV2 | null; client: DaemonClient | null;
placeholder?: string; placeholder?: string;
autoFocus?: boolean; autoFocus?: boolean;
disabled?: boolean; disabled?: boolean;
@@ -20,7 +20,7 @@ import type {
} from "@server/shared/messages"; } from "@server/shared/messages";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types"; import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
import type { DaemonClientV2, ConnectionState } from "@server/client/daemon-client-v2"; import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
import { File } from "expo-file-system"; import { File } from "expo-file-system";
import { useDaemonConnections } from "./daemon-connections-context"; import { useDaemonConnections } from "./daemon-connections-context";
import { import {
@@ -1,8 +1,8 @@
import { generateMessageId } from "@/types/stream"; import { generateMessageId } from "@/types/stream";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; import type { DaemonClient } from "@server/client/daemon-client";
export type DictationStreamSenderParams = { export type DictationStreamSenderParams = {
client: DaemonClientV2 | null; client: DaemonClient | null;
format: string; format: string;
createDictationId?: () => string; createDictationId?: () => string;
}; };
@@ -22,7 +22,7 @@ type DictationFinishResult = { dictationId: string; text: string };
* so enqueues can't "miss" a flush due to in-flight await/coalescing bugs. * so enqueues can't "miss" a flush due to in-flight await/coalescing bugs.
*/ */
export class DictationStreamSender { export class DictationStreamSender {
private client: DaemonClientV2 | null; private client: DaemonClient | null;
private readonly format: string; private readonly format: string;
private readonly createDictationId: () => string; private readonly createDictationId: () => string;
@@ -40,7 +40,7 @@ export class DictationStreamSender {
this.createDictationId = params.createDictationId ?? generateMessageId; this.createDictationId = params.createDictationId ?? generateMessageId;
} }
setClient(client: DaemonClientV2 | null): void { setClient(client: DaemonClient | null): void {
this.client = client; this.client = client;
} }
@@ -1,12 +1,12 @@
import { useEffect, useRef, useCallback } from "react"; import { useEffect, useRef, useCallback } from "react";
import { AppState, Platform } from "react-native"; import { AppState, Platform } from "react-native";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; import type { DaemonClient } from "@server/client/daemon-client";
const HEARTBEAT_INTERVAL_MS = 15_000; const HEARTBEAT_INTERVAL_MS = 15_000;
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000; const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
interface ClientActivityOptions { interface ClientActivityOptions {
client: DaemonClientV2; client: DaemonClient;
focusedAgentId: string | null; focusedAgentId: string | null;
} }
+3 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { AppState } from "react-native"; import { AppState } from "react-native";
import { DaemonClientV2 } from "@server/client/daemon-client-v2"; import { DaemonClient } from "@server/client/daemon-client";
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport"; import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
function runDaemonRequest(label: string, promise: Promise<unknown>): void { function runDaemonRequest(label: string, promise: Promise<unknown>): void {
@@ -9,11 +9,11 @@ function runDaemonRequest(label: string, promise: Promise<unknown>): void {
}); });
} }
export function useDaemonClient(url: string): DaemonClientV2 { export function useDaemonClient(url: string): DaemonClient {
const client = useMemo( const client = useMemo(
() => { () => {
const tauriTransportFactory = createTauriWebSocketTransportFactory(); const tauriTransportFactory = createTauriWebSocketTransportFactory();
return new DaemonClientV2({ return new DaemonClient({
url, url,
suppressSendErrors: true, suppressSendErrors: true,
...(tauriTransportFactory ...(tauriTransportFactory
@@ -1,7 +1,7 @@
export type DictationStatus = "idle" | "recording" | "uploading" | "failed"; export type DictationStatus = "idle" | "recording" | "uploading" | "failed";
export type UseDictationOptions = { export type UseDictationOptions = {
client: import("@server/client/daemon-client-v2").DaemonClientV2 | null; client: import("@server/client/daemon-client").DaemonClient | null;
onTranscript: (text: string, meta: { requestId: string }) => void; onTranscript: (text: string, meta: { requestId: string }) => void;
onPartialTranscript?: (text: string, meta: { requestId: string }) => void; onPartialTranscript?: (text: string, meta: { requestId: string }) => void;
onError?: (error: Error) => void; onError?: (error: Error) => void;
@@ -3,7 +3,7 @@ import { Platform } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Notifications from "expo-notifications"; import * as Notifications from "expo-notifications";
import Constants from "expo-constants"; import Constants from "expo-constants";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; import type { DaemonClient } from "@server/client/daemon-client";
const STORAGE_PREFIX = "@paseo:expo-push-token:"; const STORAGE_PREFIX = "@paseo:expo-push-token:";
@@ -26,7 +26,7 @@ async function ensurePushPermission(): Promise<boolean> {
} }
export function usePushTokenRegistration(params: { export function usePushTokenRegistration(params: {
client: DaemonClientV2; client: DaemonClient;
serverId: string; serverId: string;
}): void { }): void {
const { client, serverId } = params; const { client, serverId } = params;
+6 -6
View File
@@ -1,6 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware"; import { subscribeWithSelector } from "zustand/middleware";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; import type { DaemonClient } from "@server/client/daemon-client";
import type { useAudioPlayer } from "@/hooks/use-audio-player"; import type { useAudioPlayer } from "@/hooks/use-audio-player";
import type { AgentDirectoryEntry } from "@/types/agent-directory"; import type { AgentDirectoryEntry } from "@/types/agent-directory";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
@@ -150,7 +150,7 @@ export interface SessionState {
serverId: string; serverId: string;
// Daemon client (immutable reference) // Daemon client (immutable reference)
client: DaemonClientV2 | null; client: DaemonClient | null;
// Connection snapshot (mutable) // Connection snapshot (mutable)
connection: DaemonConnectionSnapshot; connection: DaemonConnectionSnapshot;
@@ -205,10 +205,10 @@ interface SessionStoreState {
// Action types // Action types
interface SessionStoreActions { interface SessionStoreActions {
// Session management // Session management
initializeSession: (serverId: string, client: DaemonClientV2, audioPlayer: ReturnType<typeof useAudioPlayer>) => void; initializeSession: (serverId: string, client: DaemonClient, audioPlayer: ReturnType<typeof useAudioPlayer>) => void;
clearSession: (serverId: string) => void; clearSession: (serverId: string) => void;
getSession: (serverId: string) => SessionState | undefined; getSession: (serverId: string) => SessionState | undefined;
updateSessionClient: (serverId: string, client: DaemonClientV2) => void; updateSessionClient: (serverId: string, client: DaemonClient) => void;
updateSessionConnection: (serverId: string, connection: DaemonConnectionSnapshot) => void; updateSessionConnection: (serverId: string, connection: DaemonConnectionSnapshot) => void;
// Audio state // Audio state
@@ -280,7 +280,7 @@ function logSessionStoreUpdate(
} }
function createDefaultConnectionSnapshot(client?: DaemonClientV2 | null): DaemonConnectionSnapshot { function createDefaultConnectionSnapshot(client?: DaemonClient | null): DaemonConnectionSnapshot {
if (!client) { if (!client) {
return { isConnected: false, isConnecting: false, lastError: null }; return { isConnected: false, isConnecting: false, lastError: null };
} }
@@ -293,7 +293,7 @@ function createDefaultConnectionSnapshot(client?: DaemonClientV2 | null): Daemon
} }
// Helper to create initial session state // Helper to create initial session state
function createInitialSessionState(serverId: string, client: DaemonClientV2, audioPlayer: ReturnType<typeof useAudioPlayer>): SessionState { function createInitialSessionState(serverId: string, client: DaemonClient, audioPlayer: ReturnType<typeof useAudioPlayer>): SessionState {
return { return {
serverId, serverId,
client, client,
@@ -1,4 +1,4 @@
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client-v2"; import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
type TauriWebSocketMessage = type TauriWebSocketMessage =
| { type: "Text"; data: string } | { type: "Text"; data: string }
@@ -1,5 +1,5 @@
import { DaemonClientV2 } from "@server/client/daemon-client-v2"; import { DaemonClient } from "@server/client/daemon-client";
import type { ConnectionState } from "@server/client/daemon-client-v2"; import type { ConnectionState } from "@server/client/daemon-client";
import { buildDaemonWebSocketUrl } from "./daemon-endpoints"; import { buildDaemonWebSocketUrl } from "./daemon-endpoints";
function normalizeNonEmptyString(value: unknown): string | null { function normalizeNonEmptyString(value: unknown): string | null {
@@ -45,7 +45,7 @@ export async function testDaemonEndpointConnection(
const timeoutMs = options?.timeoutMs ?? 6000; const timeoutMs = options?.timeoutMs ?? 6000;
const url = buildDaemonWebSocketUrl(endpoint); const url = buildDaemonWebSocketUrl(endpoint);
const client = new DaemonClientV2({ const client = new DaemonClient({
url, url,
suppressSendErrors: true, suppressSendErrors: true,
}); });
+1 -1
View File
@@ -11,7 +11,7 @@
- `loadConfig`, `resolvePaseoHome` - `loadConfig`, `resolvePaseoHome`
- `createRootLogger`, `LogLevel`, `LogFormat` - `createRootLogger`, `LogLevel`, `LogFormat`
- `loadPersistedConfig`, `PersistedConfig` - `loadPersistedConfig`, `PersistedConfig`
- `DaemonClientV2`, `DaemonClientV2Config`, `ConnectionState`, `DaemonEvent` - `DaemonClient`, `DaemonClientConfig`, `ConnectionState`, `DaemonEvent`
No agent snapshot/timeline/permission/message types are exported. No agent snapshot/timeline/permission/message types are exported.
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Command } from 'commander' import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js' import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { import type {
DaemonClientV2, DaemonClient,
AgentStreamMessage, AgentStreamMessage,
AgentStreamSnapshotMessage, AgentStreamSnapshotMessage,
AgentStreamEventPayload, AgentStreamEventPayload,
@@ -107,7 +107,7 @@ export async function runAttachCommand(
process.exit(1) process.exit(1)
} }
let client: DaemonClientV2 let client: DaemonClient
try { try {
client = await connectToDaemon({ host: options.host as string | undefined }) client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) { } catch (err) {
+3 -3
View File
@@ -2,7 +2,7 @@ import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js' import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions } from '../../output/index.js' import type { CommandOptions } from '../../output/index.js'
import type { import type {
DaemonClientV2, DaemonClient,
AgentStreamMessage, AgentStreamMessage,
AgentStreamSnapshotMessage, AgentStreamSnapshotMessage,
AgentTimelineItem, AgentTimelineItem,
@@ -80,7 +80,7 @@ export async function runLogsCommand(
process.exit(1) process.exit(1)
} }
let client: DaemonClientV2 let client: DaemonClient
try { try {
client = await connectToDaemon({ host: options.host as string | undefined }) client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) { } catch (err) {
@@ -172,7 +172,7 @@ export async function runLogsCommand(
* Follow mode: stream logs in real-time until interrupted * Follow mode: stream logs in real-time until interrupted
*/ */
async function runFollowMode( async function runFollowMode(
client: DaemonClientV2, client: DaemonClient,
agentId: string, agentId: string,
options: AgentLogsOptions options: AgentLogsOptions
): Promise<void> { ): Promise<void> {
+4 -4
View File
@@ -1,4 +1,4 @@
import { DaemonClientV2 } from '@paseo/server' import { DaemonClient } from '@paseo/server'
import WebSocket from 'ws' import WebSocket from 'ws'
export interface ConnectOptions { export interface ConnectOptions {
@@ -35,12 +35,12 @@ function createNodeWebSocketFactory() {
* Create and connect a daemon client * Create and connect a daemon client
* Returns the connected client or throws if connection fails * Returns the connected client or throws if connection fails
*/ */
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClientV2> { export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
const host = getDaemonHost(options) const host = getDaemonHost(options)
const timeout = options?.timeout ?? DEFAULT_TIMEOUT const timeout = options?.timeout ?? DEFAULT_TIMEOUT
const url = `ws://${host}/ws` const url = `ws://${host}/ws`
const client = new DaemonClientV2({ const client = new DaemonClient({
url, url,
webSocketFactory: createNodeWebSocketFactory(), webSocketFactory: createNodeWebSocketFactory(),
reconnect: { enabled: false }, reconnect: { enabled: false },
@@ -76,7 +76,7 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
/** /**
* Try to connect to the daemon, returns null if connection fails * Try to connect to the daemon, returns null if connection fails
*/ */
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClientV2 | null> { export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClient | null> {
try { try {
return await connectToDaemon(options) return await connectToDaemon(options)
} catch { } catch {
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { DaemonClientV2, type DaemonTransport } from "./daemon-client-v2"; import { DaemonClient, type DaemonTransport } from "./daemon-client";
function createMockLogger() { function createMockLogger() {
return { return {
@@ -49,8 +49,8 @@ function createMockTransport() {
}; };
} }
describe("DaemonClientV2", () => { describe("DaemonClient", () => {
const clients: DaemonClientV2[] = []; const clients: DaemonClient[] = [];
afterEach(async () => { afterEach(async () => {
for (const client of clients) { for (const client of clients) {
@@ -63,7 +63,7 @@ describe("DaemonClientV2", () => {
const logger = createMockLogger(); const logger = createMockLogger();
const mock = createMockTransport(); const mock = createMockTransport();
const client = new DaemonClientV2({ const client = new DaemonClient({
url: "ws://test", url: "ws://test",
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
@@ -150,7 +150,7 @@ describe("DaemonClientV2", () => {
}, },
}); });
const client = new DaemonClientV2({ const client = new DaemonClient({
url: "ws://test", url: "ws://test",
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
@@ -139,7 +139,7 @@ export type DaemonEvent =
export type DaemonEventHandler = (event: DaemonEvent) => void; export type DaemonEventHandler = (event: DaemonEvent) => void;
export type DaemonClientV2Config = { export type DaemonClientConfig = {
url: string; url: string;
authHeader?: string; authHeader?: string;
suppressSendErrors?: boolean; suppressSendErrors?: boolean;
@@ -256,7 +256,7 @@ interface PendingSend {
timeoutHandle: ReturnType<typeof setTimeout>; timeoutHandle: ReturnType<typeof setTimeout>;
} }
export class DaemonClientV2 { export class DaemonClient {
private transport: DaemonTransport | null = null; private transport: DaemonTransport | null = null;
private transportCleanup: Array<() => void> = []; private transportCleanup: Array<() => void> = [];
private rawMessageListeners: Set<(message: SessionOutboundMessage) => void> = new Set(); private rawMessageListeners: Set<(message: SessionOutboundMessage) => void> = new Set();
@@ -285,7 +285,7 @@ export class DaemonClientV2 {
private logger: Logger; private logger: Logger;
private pendingSendQueue: PendingSend[] = []; private pendingSendQueue: PendingSend[] = [];
constructor(private config: DaemonClientV2Config) { constructor(private config: DaemonClientConfig) {
this.logger = config.logger ?? consoleLogger; this.logger = config.logger ?? consoleLogger;
} }
@@ -326,7 +326,7 @@ describe("Codex app-server provider (integration)", () => {
"*** Add File: patch.txt", "*** Add File: patch.txt",
"+patched", "+patched",
"*** End Patch", "*** End Patch",
].join("\\n"); ].join("\n");
const patchEvents = session.stream( const patchEvents = session.stream(
[ [
"Use the apply_patch tool and nothing else.", "Use the apply_patch tool and nothing else.",
@@ -335,7 +335,7 @@ describe("Codex app-server provider (integration)", () => {
"Apply the following patch exactly:", "Apply the following patch exactly:",
patch, patch,
"After it completes, reply PATCH_DONE.", "After it completes, reply PATCH_DONE.",
].join("\\n") ].join("\n")
); );
for await (const event of patchEvents) { for await (const event of patchEvents) {
@@ -612,7 +612,7 @@ describe("Codex app-server provider (integration)", () => {
"*** Add File: approval-test.txt", "*** Add File: approval-test.txt",
"+ok", "+ok",
"*** End Patch", "*** End Patch",
].join("\\n"); ].join("\n");
const events = session.stream( const events = session.stream(
[ [
"Use the apply_patch tool and nothing else.", "Use the apply_patch tool and nothing else.",
@@ -621,7 +621,7 @@ describe("Codex app-server provider (integration)", () => {
"Apply the following patch exactly:", "Apply the following patch exactly:",
patch, patch,
"After approval, reply FILE_DONE.", "After approval, reply FILE_DONE.",
].join("\\n") ].join("\n")
); );
let failure: string | null = null; let failure: string | null = null;
@@ -18,7 +18,7 @@ import {
} from "./test-utils/dictation-e2e.js"; } from "./test-utils/dictation-e2e.js";
function tmpCwd(): string { function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-client-v2-")); return mkdtempSync(path.join(tmpdir(), "daemon-client-"));
} }
function waitForSignal<T>( function waitForSignal<T>(
@@ -52,7 +52,7 @@ function waitForSignal<T>(
}); });
} }
describe("daemon client v2 E2E", () => { describe("daemon client E2E", () => {
let ctx: DaemonTestContext; let ctx: DaemonTestContext;
beforeAll(async () => { beforeAll(async () => {
@@ -10,7 +10,7 @@
*/ */
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { DaemonClientV2 } from "../../client/daemon-client-v2.js"; import { DaemonClient } from "../../client/daemon-client.js";
// Patch WebSocket to log all messages // Patch WebSocket to log all messages
const OriginalWebSocket = WebSocket; const OriginalWebSocket = WebSocket;
@@ -36,7 +36,7 @@ async function testMultiAgentSequence() {
console.log("\n=== Testing multi-agent checkout sequence ==="); console.log("\n=== Testing multi-agent checkout sequence ===");
console.log(`Daemon URL: ${DAEMON_URL}`); console.log(`Daemon URL: ${DAEMON_URL}`);
const client = new DaemonClientV2({ const client = new DaemonClient({
url: DAEMON_URL, url: DAEMON_URL,
webSocketFactory: (url) => new LoggingWebSocket(url) as any, webSocketFactory: (url) => new LoggingWebSocket(url) as any,
reconnect: { enabled: false }, reconnect: { enabled: false },
+1 -1
View File
@@ -4,7 +4,7 @@ export { loadConfig } from "./config.js";
export { resolvePaseoHome } from "./paseo-home.js"; export { resolvePaseoHome } from "./paseo-home.js";
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js"; export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js"; export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
export { DaemonClientV2, type DaemonClientV2Config, type ConnectionState, type DaemonEvent } from "../client/daemon-client-v2.js"; export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent } from "../client/daemon-client.js";
// Agent SDK types for CLI commands // Agent SDK types for CLI commands
export type { export type {
+44 -3
View File
@@ -1987,7 +1987,11 @@ export class Session {
} }
private async generateCommitMessage(cwd: string): Promise<string> { private async generateCommitMessage(cwd: string): Promise<string> {
const diff = await getCheckoutDiff(cwd, { mode: "uncommitted" }, { paseoHome: this.paseoHome }); const diff = await getCheckoutDiff(
cwd,
{ mode: "uncommitted", includeStructured: true },
{ paseoHome: this.paseoHome }
);
const schema = z.object({ const schema = z.object({
message: z message: z
.string() .string()
@@ -1995,11 +1999,29 @@ export class Session {
.max(72) .max(72)
.describe("Concise git commit message, imperative mood, no trailing period."), .describe("Concise git commit message, imperative mood, no trailing period."),
}); });
const fileList =
diff.structured && diff.structured.length > 0
? [
"Files changed:",
...diff.structured.map((file) => {
const changeType = file.isNew ? "A" : file.isDeleted ? "D" : "M";
const status = file.status && file.status !== "ok" ? ` [${file.status}]` : "";
return `${changeType}\t${file.path}\t(+${file.additions} -${file.deletions})${status}`;
}),
].join("\n")
: "Files changed: (unknown)";
const maxPatchChars = 120_000;
const patch =
diff.diff.length > maxPatchChars
? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n`
: diff.diff;
const prompt = [ const prompt = [
"Write a concise git commit message for the changes below.", "Write a concise git commit message for the changes below.",
"Return JSON only with a single field 'message'.", "Return JSON only with a single field 'message'.",
"", "",
diff.diff.length > 0 ? diff.diff : "(No diff available)", fileList,
"",
patch.length > 0 ? patch : "(No diff available)",
].join("\n"); ].join("\n");
try { try {
const result = await generateStructuredAgentResponse({ const result = await generateStructuredAgentResponse({
@@ -2034,6 +2056,7 @@ export class Session {
{ {
mode: "base", mode: "base",
baseRef, baseRef,
includeStructured: true,
}, },
{ paseoHome: this.paseoHome } { paseoHome: this.paseoHome }
); );
@@ -2041,11 +2064,29 @@ export class Session {
title: z.string().min(1).max(72), title: z.string().min(1).max(72),
body: z.string().min(1), body: z.string().min(1),
}); });
const fileList =
diff.structured && diff.structured.length > 0
? [
"Files changed:",
...diff.structured.map((file) => {
const changeType = file.isNew ? "A" : file.isDeleted ? "D" : "M";
const status = file.status && file.status !== "ok" ? ` [${file.status}]` : "";
return `${changeType}\t${file.path}\t(+${file.additions} -${file.deletions})${status}`;
}),
].join("\n")
: "Files changed: (unknown)";
const maxPatchChars = 200_000;
const patch =
diff.diff.length > maxPatchChars
? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n`
: diff.diff;
const prompt = [ const prompt = [
"Write a pull request title and body for the changes below.", "Write a pull request title and body for the changes below.",
"Return JSON only with fields 'title' and 'body'.", "Return JSON only with fields 'title' and 'body'.",
"", "",
diff.diff.length > 0 ? diff.diff : "(No diff available)", fileList,
"",
patch.length > 0 ? patch : "(No diff available)",
].join("\n"); ].join("\n");
try { try {
return await generateStructuredAgentResponse({ return await generateStructuredAgentResponse({
@@ -1,13 +1,13 @@
import WebSocket from "ws"; import WebSocket from "ws";
import { import {
DaemonClientV2 as SharedDaemonClient, DaemonClient as SharedDaemonClient,
type DaemonClientV2Config as SharedDaemonClientConfig, type DaemonClientConfig as SharedDaemonClientConfig,
type CreateAgentRequestOptions, type CreateAgentRequestOptions,
type DaemonEvent, type DaemonEvent,
type DaemonEventHandler, type DaemonEventHandler,
type SendMessageOptions, type SendMessageOptions,
type WebSocketLike, type WebSocketLike,
} from "../../client/daemon-client-v2.js"; } from "../../client/daemon-client.js";
export type DaemonClientConfig = Omit< export type DaemonClientConfig = Omit<
SharedDaemonClientConfig, SharedDaemonClientConfig,
@@ -1,4 +1,4 @@
import type { DaemonClientV2 } from "../../client/daemon-client-v2.js"; import type { DaemonClient } from "../../client/daemon-client.js";
import type { SessionOutboundMessage } from "../../shared/messages.js"; import type { SessionOutboundMessage } from "../../shared/messages.js";
export interface MessageCollector { export interface MessageCollector {
@@ -7,7 +7,7 @@ export interface MessageCollector {
unsubscribe: () => void; unsubscribe: () => void;
} }
export function createMessageCollector(client: DaemonClientV2): MessageCollector { export function createMessageCollector(client: DaemonClient): MessageCollector {
const messages: SessionOutboundMessage[] = []; const messages: SessionOutboundMessage[] = [];
const unsubscribe = client.subscribeRawMessages((message) => { const unsubscribe = client.subscribeRawMessages((message) => {
messages.push(message); messages.push(message);
@@ -20,4 +20,3 @@ export function createMessageCollector(client: DaemonClientV2): MessageCollector
unsubscribe, unsubscribe,
}; };
} }
@@ -102,6 +102,36 @@ describe("checkout git utilities", () => {
expect(logMessage).toBe(message); expect(logMessage).toBe(message);
}); });
it("diffs base mode against merge-base (no base-only deletions)", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
// Advance base branch after feature splits off.
execSync("git checkout main", { cwd: repoDir });
writeFileSync(join(repoDir, "base-only.txt"), "base\n");
execSync("git add base-only.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'base only'", { cwd: repoDir });
// Make a feature change.
execSync("git checkout feature", { cwd: repoDir });
writeFileSync(join(repoDir, "feature.txt"), "feature\n");
execSync("git add feature.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir });
const diff = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "main" });
expect(diff.diff).toContain("feature.txt");
expect(diff.diff).not.toContain("base-only.txt");
});
it("does not throw on large diffs (marks file as too_large)", async () => {
const large = Array.from({ length: 200_000 }, (_, i) => `line ${i}`).join("\n") + "\n";
writeFileSync(join(repoDir, "file.txt"), large);
const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted", includeStructured: true });
expect(diff.structured?.some((f) => f.path === "file.txt" && f.status === "too_large")).toBe(
true
);
});
it("handles status/diff/commit in a .paseo worktree", async () => { it("handles status/diff/commit in a .paseo worktree", async () => {
const result = await createWorktree({ const result = await createWorktree({
branchName: "main", branchName: "main",
+334 -46
View File
@@ -1,4 +1,4 @@
import { exec, execFile } from "child_process"; import { exec, execFile, spawn } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import { resolve, dirname, basename } from "path"; import { resolve, dirname, basename } from "path";
import { realpathSync } from "fs"; import { realpathSync } from "fs";
@@ -14,6 +14,203 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
GIT_OPTIONAL_LOCKS: "0", GIT_OPTIONAL_LOCKS: "0",
}; };
const SMALL_OUTPUT_MAX_BUFFER = 20 * 1024 * 1024; // 20MB
async function execGit(command: string, options: { cwd: string; env?: NodeJS.ProcessEnv }): Promise<{ stdout: string; stderr: string }> {
return execAsync(command, { ...options, maxBuffer: SMALL_OUTPUT_MAX_BUFFER });
}
async function execGitFile(
args: string[],
options: { cwd: string; env?: NodeJS.ProcessEnv }
): Promise<{ stdout: string; stderr: string }> {
return execFileAsync("git", args, { ...options, maxBuffer: SMALL_OUTPUT_MAX_BUFFER });
}
type LimitedTextResult = {
text: string;
truncated: boolean;
exitCode: number | null;
signal: NodeJS.Signals | null;
};
async function spawnLimitedText(params: {
cmd: string;
args: string[];
cwd: string;
env?: NodeJS.ProcessEnv;
maxBytes: number;
acceptExitCodes?: number[];
}): Promise<LimitedTextResult> {
const accept = new Set(params.acceptExitCodes ?? [0]);
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(params.cmd, params.args, {
cwd: params.cwd,
env: params.env,
stdio: ["ignore", "pipe", "pipe"],
});
const stdoutChunks: Buffer[] = [];
let stdoutBytes = 0;
let truncated = false;
const stop = () => {
if (child.killed) return;
try {
child.kill("SIGKILL");
} catch {
// ignore
}
};
child.stdout.on("data", (chunk: Buffer) => {
if (truncated) return;
stdoutBytes += chunk.length;
if (stdoutBytes > params.maxBytes) {
truncated = true;
stop();
return;
}
stdoutChunks.push(chunk);
});
// We don't buffer stderr (it can be large too). Keep it minimal for debugging.
let stderrPreview = "";
child.stderr.on("data", (chunk: Buffer) => {
if (stderrPreview.length > 2048) return;
stderrPreview += chunk.toString("utf8");
});
child.on("error", (error) => {
rejectPromise(error);
});
child.on("close", (code, signal) => {
if (code !== null && !accept.has(code) && !truncated) {
rejectPromise(new Error(`Command failed: ${params.cmd} ${params.args.join(" ")} (code ${code})\n${stderrPreview}`));
return;
}
resolvePromise({
text: Buffer.concat(stdoutChunks).toString("utf8"),
truncated,
exitCode: code,
signal,
});
});
});
}
type CheckoutFileChange = {
path: string;
oldPath?: string;
status: string;
isNew: boolean;
isDeleted: boolean;
isUntracked?: boolean;
};
async function listCheckoutFileChanges(cwd: string, ref: string): Promise<CheckoutFileChange[]> {
const changes: CheckoutFileChange[] = [];
const { stdout: nameStatusOut } = await execGit(`git diff --name-status ${ref}`, {
cwd,
env: READ_ONLY_GIT_ENV,
});
for (const line of nameStatusOut.split("\n").map((l) => l.trim()).filter(Boolean)) {
// `--name-status` uses TAB separators, which preserves filenames with spaces.
const tabParts = line.split("\t");
const rawStatus = (tabParts[0] ?? "").trim();
if (!rawStatus) continue;
if (rawStatus.startsWith("R") || rawStatus.startsWith("C")) {
const oldPath = tabParts[1];
const newPath = tabParts[2];
if (newPath) {
changes.push({
path: newPath,
...(oldPath ? { oldPath } : {}),
status: rawStatus,
isNew: false,
isDeleted: false,
});
}
continue;
}
const path = tabParts[1];
if (!path) continue;
const code = rawStatus[0];
changes.push({
path,
status: rawStatus,
isNew: code === "A",
isDeleted: code === "D",
});
}
const { stdout: untrackedOut } = await execGit("git ls-files --others --exclude-standard", {
cwd,
env: READ_ONLY_GIT_ENV,
});
for (const file of untrackedOut.split("\n").map((l) => l.trim()).filter(Boolean)) {
changes.push({
path: file,
status: "U",
isNew: true,
isDeleted: false,
isUntracked: true,
});
}
// Deduplicate by path (prefer tracked status over untracked marker if both appear).
const byPath = new Map<string, CheckoutFileChange>();
for (const change of changes) {
const existing = byPath.get(change.path);
if (!existing) {
byPath.set(change.path, change);
continue;
}
if (existing.isUntracked && !change.isUntracked) {
byPath.set(change.path, change);
}
}
return Array.from(byPath.values());
}
async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string | null> {
try {
const { stdout } = await execGit(`git merge-base ${baseRef} HEAD`, { cwd, env: READ_ONLY_GIT_ENV });
const sha = stdout.trim();
return sha.length > 0 ? sha : null;
} catch {
return null;
}
}
type FileStat = { additions: number; deletions: number; isBinary: boolean } | null;
async function tryGetNumstat(cwd: string, args: string[]): Promise<FileStat> {
try {
const { stdout } = await execGitFile(args, { cwd, env: READ_ONLY_GIT_ENV });
const line = stdout.trim().split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
if (!line) return null;
const [aRaw, dRaw] = line.split(/\s+/);
if (!aRaw || !dRaw) return null;
if (aRaw === "-" || dRaw === "-") {
return { additions: 0, deletions: 0, isBinary: true };
}
const additions = Number.parseInt(aRaw, 10);
const deletions = Number.parseInt(dRaw, 10);
if (Number.isNaN(additions) || Number.isNaN(deletions)) {
return null;
}
return { additions, deletions, isBinary: false };
} catch {
return null;
}
}
export class NotGitRepoError extends Error { export class NotGitRepoError extends Error {
readonly cwd: string; readonly cwd: string;
readonly code = "NOT_GIT_REPO"; readonly code = "NOT_GIT_REPO";
@@ -435,32 +632,58 @@ async function getAheadOfOrigin(cwd: string, currentBranch: string): Promise<num
} }
} }
async function getUntrackedDiff(cwd: string): Promise<string> { const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
let untrackedDiff = ""; const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
try {
const { stdout: untrackedFiles } = await execAsync(
"git ls-files --others --exclude-standard",
{ cwd, env: READ_ONLY_GIT_ENV }
);
const newFiles = untrackedFiles.trim().split("\n").filter(Boolean);
for (const file of newFiles) { function buildPlaceholderParsedDiffFile(
try { change: CheckoutFileChange,
const { stdout: fileDiff } = await execAsync( options: { status: "too_large" | "binary"; stat?: FileStat }
`git diff --no-index /dev/null "${file}" || true`, ): ParsedDiffFile {
{ cwd, env: READ_ONLY_GIT_ENV } return {
); path: change.path,
if (fileDiff) { isNew: change.isNew,
untrackedDiff += fileDiff; isDeleted: change.isDeleted,
} additions: options.stat?.additions ?? 0,
} catch { deletions: options.stat?.deletions ?? 0,
// Ignore errors for individual files hunks: [],
} status: options.status,
} };
} catch { }
// Ignore errors getting untracked files
async function getPerFileDiffText(
cwd: string,
ref: string,
change: CheckoutFileChange
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
const stat: FileStat =
change.isUntracked
? null
: await tryGetNumstat(cwd, ["diff", "--numstat", ref, "--", change.path]);
if (stat?.isBinary) {
return { text: "", truncated: false, stat };
} }
return untrackedDiff;
if (change.isUntracked) {
const result = await spawnLimitedText({
cmd: "git",
args: ["diff", "--no-index", "/dev/null", "--", change.path],
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: PER_FILE_DIFF_MAX_BYTES,
acceptExitCodes: [0, 1],
});
return { text: result.text, truncated: result.truncated, stat };
}
const result = await spawnLimitedText({
cmd: "git",
args: ["diff", ref, "--", change.path],
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: PER_FILE_DIFF_MAX_BYTES,
});
return { text: result.text, truncated: result.truncated, stat };
} }
export async function getCheckoutStatus( export async function getCheckoutStatus(
@@ -530,37 +753,102 @@ export async function getCheckoutDiff(
): Promise<CheckoutDiffResult> { ): Promise<CheckoutDiffResult> {
await requireGitRepo(cwd); await requireGitRepo(cwd);
let diff = ""; let refForDiff: string;
if (compare.mode === "uncommitted") { if (compare.mode === "uncommitted") {
const { stdout: trackedDiff } = await execAsync("git diff HEAD", { refForDiff = "HEAD";
cwd,
env: READ_ONLY_GIT_ENV,
});
const untrackedDiff = await getUntrackedDiff(cwd);
diff = trackedDiff + untrackedDiff;
} else { } else {
const configured = await getConfiguredBaseRefForCwd(cwd, context); const configured = await getConfiguredBaseRefForCwd(cwd, context);
const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(cwd)); const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(cwd));
if (!baseRef) { if (!baseRef) {
diff = ""; return { diff: "" };
} else if (configured.isPaseoOwnedWorktree && compare.baseRef && compare.baseRef !== baseRef) {
throw new Error(`Base ref mismatch: expected ${baseRef}, got ${compare.baseRef}`);
} else {
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
// Diff base ref against working tree (includes uncommitted changes)
const { stdout: trackedDiff } = await execAsync(`git diff ${normalizedBaseRef}`, {
cwd,
env: READ_ONLY_GIT_ENV,
});
const untrackedDiff = await getUntrackedDiff(cwd);
diff = trackedDiff + untrackedDiff;
} }
if (configured.isPaseoOwnedWorktree && compare.baseRef && compare.baseRef !== baseRef) {
throw new Error(`Base ref mismatch: expected ${baseRef}, got ${compare.baseRef}`);
}
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
const bestBaseRef = await resolveBestBaseRefForMerge(cwd, normalizedBaseRef);
refForDiff = (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef;
}
const changes = await listCheckoutFileChanges(cwd, refForDiff);
changes.sort((a, b) => a.path.localeCompare(b.path));
const structured: ParsedDiffFile[] = [];
let diffText = "";
let diffBytes = 0;
const appendDiff = (text: string) => {
if (!text) return;
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) return;
const buf = Buffer.from(text, "utf8");
if (diffBytes + buf.length <= TOTAL_DIFF_MAX_BYTES) {
diffText += text;
diffBytes += buf.length;
return;
}
const remaining = TOTAL_DIFF_MAX_BYTES - diffBytes;
if (remaining > 0) {
diffText += buf.subarray(0, remaining).toString("utf8");
diffBytes = TOTAL_DIFF_MAX_BYTES;
}
};
for (const change of changes) {
const { text, truncated, stat } = await getPerFileDiffText(cwd, refForDiff, change);
if (!compare.includeStructured) {
if (stat?.isBinary) {
appendDiff(`# ${change.path}: binary diff omitted\n`);
} else if (truncated) {
appendDiff(`# ${change.path}: diff too large omitted\n`);
} else {
appendDiff(text);
}
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
break;
}
continue;
}
if (stat?.isBinary) {
structured.push(buildPlaceholderParsedDiffFile(change, { status: "binary", stat }));
appendDiff(`# ${change.path}: binary diff omitted\n`);
continue;
}
if (truncated) {
structured.push(buildPlaceholderParsedDiffFile(change, { status: "too_large", stat }));
appendDiff(`# ${change.path}: diff too large omitted\n`);
continue;
}
appendDiff(text);
const parsed = await parseAndHighlightDiff(text, cwd);
const parsedFile =
parsed[0] ??
({
path: change.path,
isNew: change.isNew,
isDeleted: change.isDeleted,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
hunks: [],
} satisfies ParsedDiffFile);
structured.push({
...parsedFile,
path: change.path,
isNew: change.isNew,
isDeleted: change.isDeleted,
status: "ok",
});
} }
if (compare.includeStructured) { if (compare.includeStructured) {
return { diff, structured: await parseAndHighlightDiff(diff, cwd) }; return { diff: diffText, structured };
} }
return { diff }; return { diff: diffText };
} }
export async function commitChanges( export async function commitChanges(