diff --git a/package-lock.json b/package-lock.json index c19a5d610..83ac77802 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1385,6 +1385,27 @@ "react-native": "*" } }, + "node_modules/@clack/core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0.tgz", + "integrity": "sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.0.tgz", + "integrity": "sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.0.0", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.4.1", "license": "MIT OR Apache-2.0", @@ -19899,6 +19920,7 @@ "name": "@getpaseo/cli", "version": "0.1.2", "dependencies": { + "@clack/prompts": "^1.0.0", "@getpaseo/relay": "0.1.2", "@getpaseo/server": "0.1.2", "chalk": "^5.3.0", diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index fa27e6538..dd7990720 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -506,6 +506,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { }); const { status: prStatus, + githubFeaturesEnabled, payloadError: prPayloadError, refresh: refreshPrStatus, } = useCheckoutPrStatusQuery({ @@ -840,7 +841,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { const diffErrorMessage = diffPayloadError?.message ?? (isDiffError && diffError instanceof Error ? diffError.message : null); - const prErrorMessage = prPayloadError?.message ?? null; + const prErrorMessage = githubFeaturesEnabled ? prPayloadError?.message ?? null : null; const branchLabel = gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch @@ -993,7 +994,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { } // View PR - when PR exists - if (hasPullRequest && prStatus?.url) { + if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) { const prUrl = prStatus.url; allActions.set("view-pr", { id: "view-pr", @@ -1008,7 +1009,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { } // Create PR - when ahead of base and no PR - if (aheadCount > 0 && !hasPullRequest) { + if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) { allActions.set("create-pr", { id: "create-pr", label: "Create PR", @@ -1112,7 +1113,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { return { primary, secondary, menu }; }, [ - isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, + isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled, hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel, commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled, commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus, diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index f26ed7b23..9b39cb8a0 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -322,7 +322,6 @@ export function SessionProvider({ const setPendingPermissions = useSessionStore( (state) => state.setPendingPermissions ); - const setGitDiffs = useSessionStore((state) => state.setGitDiffs); const setFileExplorer = useSessionStore((state) => state.setFileExplorer); const clearDraftInput = useDraftStore((state) => state.clearDraftInput); const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); @@ -666,22 +665,6 @@ export function SessionProvider({ [serverId, setFileExplorer] ); - const gitDiffMutation = useMutation({ - mutationFn: async ({ agentId }: { agentId: string }) => { - if (!agentId) { - throw new Error("Agent id is required"); - } - if (!client) { - throw new Error("Daemon client unavailable"); - } - const payload = await client.getGitDiff(agentId); - if (payload.error) { - throw new Error(payload.error); - } - return { agentId: payload.agentId, diff: payload.diff ?? "" }; - }, - }); - const refreshAgentMutation = useMutation({ mutationFn: async ({ agentId }: { agentId: string }) => { if (!agentId) { @@ -1275,15 +1258,6 @@ export function SessionProvider({ return next; }); - setGitDiffs(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - }); - setFileExplorer(serverId, (prev) => { if (!prev.has(agentId)) { return prev; @@ -1343,7 +1317,6 @@ export function SessionProvider({ setAgents, setAgentLastActivity, setPendingPermissions, - setGitDiffs, setFileExplorer, setHasHydratedAgents, updateConnectionStatus, @@ -1635,24 +1608,6 @@ export function SessionProvider({ [] ); - const requestGitDiff = useCallback( - (agentId: string) => { - gitDiffMutation - .mutateAsync({ agentId }) - .then((result) => { - setGitDiffs(serverId, (prev) => - new Map(prev).set(result.agentId, result.diff) - ); - }) - .catch((error) => { - setGitDiffs(serverId, (prev) => - new Map(prev).set(agentId, `Error: ${error.message}`) - ); - }); - }, - [serverId, gitDiffMutation, setGitDiffs] - ); - const requestDirectoryListing = useCallback( (agentId: string, path: string, options?: { recordHistory?: boolean }) => { const normalizedPath = path && path.length > 0 ? path : "."; diff --git a/packages/app/src/hooks/use-checkout-pr-status-query.ts b/packages/app/src/hooks/use-checkout-pr-status-query.ts index 254219a46..08681a301 100644 --- a/packages/app/src/hooks/use-checkout-pr-status-query.ts +++ b/packages/app/src/hooks/use-checkout-pr-status-query.ts @@ -43,6 +43,7 @@ export function useCheckoutPrStatusQuery({ return { status: query.data?.status ?? null, + githubFeaturesEnabled: query.data?.githubFeaturesEnabled ?? true, payloadError: query.data?.error ?? null, isLoading: query.isLoading, isFetching: query.isFetching, diff --git a/packages/app/src/hooks/use-git-diff-query.ts b/packages/app/src/hooks/use-git-diff-query.ts deleted file mode 100644 index 3495beeb6..000000000 --- a/packages/app/src/hooks/use-git-diff-query.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect } from "react"; -import { UnistylesRuntime } from "react-native-unistyles"; -import { useSessionStore } from "@/stores/session-store"; -import { usePanelStore } from "@/stores/panel-store"; - -const GIT_DIFF_STALE_TIME = 30_000; - -function gitDiffQueryKey(serverId: string, agentId: string) { - return ["gitDiff", serverId, agentId] as const; -} - -interface UseGitDiffQueryOptions { - serverId: string; - agentId: string; -} - -export function useGitDiffQuery({ serverId, agentId }: UseGitDiffQueryOptions) { - const queryClient = useQueryClient(); - const client = useSessionStore( - (state) => state.sessions[serverId]?.client ?? null - ); - const isConnected = useSessionStore( - (state) => state.sessions[serverId]?.connection.isConnected ?? false - ); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const mobileView = usePanelStore((state) => state.mobileView); - const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); - const explorerTab = usePanelStore((state) => state.explorerTab); - const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; - - const query = useQuery({ - queryKey: gitDiffQueryKey(serverId, agentId), - queryFn: async () => { - if (!client) { - throw new Error("Daemon client not available"); - } - const response = await client.getGitDiff(agentId); - return response.diff; - }, - enabled: !!client && isConnected && !!agentId, - staleTime: GIT_DIFF_STALE_TIME, - refetchInterval: 10_000, - }); - - // Revalidate when sidebar opens with "changes" tab active - useEffect(() => { - if (!isOpen || explorerTab !== "changes" || !agentId) { - return; - } - // Invalidate to trigger background refetch (shows stale data while fetching) - queryClient.invalidateQueries({ - queryKey: gitDiffQueryKey(serverId, agentId), - }); - }, [isOpen, explorerTab, serverId, agentId, queryClient]); - - const refresh = useCallback(() => { - return query.refetch(); - }, [query]); - - return { - diff: query.data ?? null, - isLoading: query.isLoading, - isFetching: query.isFetching, - isError: query.isError, - error: query.error, - refresh, - }; -} diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 53991d9fc..0956c22ef 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -201,9 +201,6 @@ export interface SessionState { // Permissions pendingPermissions: Map; - // Git diffs - gitDiffs: Map; - // File explorer fileExplorer: Map; @@ -258,9 +255,6 @@ interface SessionStoreActions { // Permissions setPendingPermissions: (serverId: string, perms: Map | ((prev: Map) => Map)) => void; - // Git diffs - setGitDiffs: (serverId: string, diffs: Map | ((prev: Map) => Map)) => void; - // File explorer setFileExplorer: (serverId: string, state: Map | ((prev: Map) => Map)) => void; @@ -332,7 +326,6 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio initializingAgents: new Map(), agents: new Map(), pendingPermissions: new Map(), - gitDiffs: new Map(), fileExplorer: new Map(), queuedMessages: new Map(), }; @@ -745,28 +738,6 @@ export const useSessionStore = create()( }); }, - // Git diffs - setGitDiffs: (serverId, diffs) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) { - return prev; - } - const nextDiffs = typeof diffs === "function" ? diffs(session.gitDiffs) : diffs; - if (session.gitDiffs === nextDiffs) { - return prev; - } - logSessionStoreUpdate("setGitDiffs", serverId, { count: nextDiffs.size }); - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, gitDiffs: nextDiffs }, - }, - }; - }); - }, - // File explorer setFileExplorer: (serverId, state) => { set((prev) => { diff --git a/packages/cli/package.json b/packages/cli/package.json index 39b1fab36..51310014f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -21,6 +21,7 @@ "test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts" }, "dependencies": { + "@clack/prompts": "^1.0.0", "@getpaseo/relay": "0.1.2", "@getpaseo/server": "0.1.2", "chalk": "^5.3.0", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index e7e726203..1794118bb 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -18,6 +18,7 @@ import { runWaitCommand } from './commands/agent/wait.js' import { runAttachCommand } from './commands/agent/attach.js' import { runUpdateCommand } from './commands/agent/update.js' import { withOutput } from './output/index.js' +import { onboardCommand } from './commands/onboard.js' const VERSION = '0.1.0' @@ -142,6 +143,7 @@ export function createCli(): Command { .action(withOutput(runUpdateCommand)) // Top-level local daemon shortcuts + program.addCommand(onboardCommand()) program.addCommand(daemonStartCommand()) program diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts new file mode 100644 index 000000000..970cf6235 --- /dev/null +++ b/packages/cli/src/commands/onboard.ts @@ -0,0 +1,693 @@ +import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from '@clack/prompts' +import { Command } from 'commander' +import { writeFileSync } from 'node:fs' +import path from 'node:path' +import { + ensureLocalSpeechModels, + generateLocalPairingOffer, + loadConfig, + loadPersistedConfig, + type LocalSpeechModelId, + type CliConfigOverrides, + type PersistedConfig, +} from '@getpaseo/server' +import { + resolveLocalPaseoHome, + resolveLocalDaemonState, + resolveTcpHostFromListen, + startLocalDaemonDetached, + tailDaemonLog, + type DaemonStartOptions, +} from './daemon/local-daemon.js' +import { tryConnectToDaemon } from '../utils/client.js' + +interface OnboardOptions extends DaemonStartOptions { + timeout?: string + voice?: 'ask' | 'enable' | 'disable' +} + +type OnboardPersistedConfig = PersistedConfig & { + providers?: PersistedConfig['providers'] & { + local?: PersistedConfig['providers'] extends { local?: infer T } ? T : { autoDownload?: boolean } + } + features?: PersistedConfig['features'] & { + dictation?: PersistedConfig['features'] extends { dictation?: infer T } + ? T & { enabled?: boolean } + : { enabled?: boolean } + voiceMode?: PersistedConfig['features'] extends { voiceMode?: infer T } + ? T & { enabled?: boolean } + : { enabled?: boolean } + } +} + +const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000 + +class OnboardCancelledError extends Error {} + +const plainNoteFormat = (line: string): string => line + +function renderNote(message: string, title: string): void { + note(message, title, { format: plainNoteFormat }) +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +function parseTimeoutMs(raw: string | undefined): number { + if (!raw || raw.trim().length === 0) { + return DEFAULT_READY_TIMEOUT_MS + } + + const seconds = Number(raw) + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new Error(`Invalid timeout value: ${raw}`) + } + + return Math.ceil(seconds * 1000) +} + +function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides { + const cliOverrides: CliConfigOverrides = {} + + if (options.listen) { + cliOverrides.listen = options.listen + } else if (options.port) { + cliOverrides.listen = `127.0.0.1:${options.port}` + } + + if (options.relay === false) { + cliOverrides.relayEnabled = false + } + + if (options.allowedHosts) { + const raw = options.allowedHosts.trim() + cliOverrides.allowedHosts = + raw.toLowerCase() === 'true' + ? true + : raw.split(',').map(host => host.trim()).filter(Boolean) + } + + if (options.mcp === false) { + cliOverrides.mcpEnabled = false + } + + return cliOverrides +} + +function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): void { + const configPath = path.join(paseoHome, 'config.json') + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) +} + +function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean): OnboardPersistedConfig { + return { + ...config, + providers: { + ...config.providers, + local: { + ...config.providers?.local, + autoDownload: enabled, + }, + }, + features: { + ...config.features, + dictation: { + ...config.features?.dictation, + enabled, + }, + voiceMode: { + ...config.features?.voiceMode, + enabled, + }, + }, + } +} + +function resolvePersistedVoiceSelection(config: OnboardPersistedConfig): boolean | null { + const voiceModeEnabled = config.features?.voiceMode?.enabled + if (typeof voiceModeEnabled === 'boolean') { + return voiceModeEnabled + } + + const dictationEnabled = config.features?.dictation?.enabled + if (typeof dictationEnabled === 'boolean') { + return dictationEnabled + } + + return null +} + +async function resolveVoiceSelection(mode: OnboardOptions['voice']): Promise { + if (mode === 'enable') { + return true + } + if (mode === 'disable') { + return false + } + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + log.message('Non-interactive terminal detected; voice setup defaults to disabled.') + return false + } + + const answer = await confirm({ + message: 'Enable voice features? (downloads local STT/TTS models now)', + active: 'Yes', + inactive: 'No', + initialValue: false, + }) + + if (isCancel(answer)) { + throw new OnboardCancelledError('Onboarding cancelled by user.') + } + + return answer +} + +type DownloadProgress = { + modelId: string | null + pct: number | null +} + +type LocalModelDownloadProgress = { + modelId: string | null + pct: number | null +} + +type LocalSpeechDownloadLogger = { + child: (_bindings: Record) => LocalSpeechDownloadLogger + info: (obj?: unknown, msg?: string) => void + error: (_obj?: unknown, _msg?: string) => void +} + +type LocalSpeechDownloadEvent = + | { + type: 'progress' + progress: LocalModelDownloadProgress + } + | { + type: 'phase' + phase: 'extracting' | 'verifying' | 'finalizing' | 'completed' + } + +function resolveRequiredLocalModelIds(config: ReturnType): LocalSpeechModelId[] { + const providers = config.speech?.providers + const local = config.speech?.local + + if (!providers || !local) { + return [] + } + + const ids = new Set() + + if (providers.dictationStt.enabled !== false && providers.dictationStt.provider === 'local') { + ids.add(local.models.dictationStt) + } + if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === 'local') { + ids.add(local.models.voiceStt) + } + if (providers.voiceTts.enabled !== false && providers.voiceTts.provider === 'local') { + ids.add(local.models.voiceTts) + } + + return Array.from(ids) +} + +function parseLocalModelDownloadProgress(payload: unknown): LocalModelDownloadProgress | null { + if (!payload || typeof payload !== 'object') { + return null + } + + const value = payload as Record + const modelId = typeof value.modelId === 'string' ? value.modelId : null + const pctRaw = value.pct + const pct = typeof pctRaw === 'number' && Number.isFinite(pctRaw) ? Math.max(0, Math.min(100, Math.floor(pctRaw))) : null + + return { + modelId, + pct, + } +} + +function renderLocalModelProgress(params: { + modelId: LocalSpeechModelId + modelIndex: number + modelCount: number + pct: number | null +}): string { + const prefix = `Downloading speech model ${params.modelIndex}/${params.modelCount}: ${params.modelId}` + if (params.pct === null) { + return `${prefix}...` + } + return `${prefix} (${params.pct}%)` +} + +function createLocalSpeechDownloadLogger( + onEvent: (event: LocalSpeechDownloadEvent) => void +): LocalSpeechDownloadLogger { + const logger: LocalSpeechDownloadLogger = { + child: () => logger, + info: (obj?: unknown, msg?: string) => { + if (msg === 'Downloading model artifact') { + const progress = parseLocalModelDownloadProgress(obj) + if (!progress) { + return + } + onEvent({ + type: 'progress', + progress, + }) + return + } + if (msg === 'Extracting model archive') { + onEvent({ type: 'phase', phase: 'extracting' }) + return + } + if (msg === 'Verifying downloaded model files') { + onEvent({ type: 'phase', phase: 'verifying' }) + return + } + if (msg === 'Finalizing model artifacts') { + onEvent({ type: 'phase', phase: 'finalizing' }) + return + } + if (msg === 'Model download completed') { + onEvent({ type: 'phase', phase: 'completed' }) + return + } + }, + error: () => { + // no-op: onboarding handles surfaced errors from ensureLocalSpeechModels. + }, + } + return logger +} + +async function prepareLocalSpeechModelsBeforeStart(args: { + config: ReturnType + richUi: boolean +}): Promise { + const local = args.config.speech?.local + const modelIds = resolveRequiredLocalModelIds(args.config) + + if (!local || modelIds.length === 0) { + return + } + + if (local.autoDownload === false) { + log.warn('Local speech model auto-download is disabled. Voice may be unavailable until models are installed.') + return + } + + const modelList = modelIds.join(', ') + const modelCount = modelIds.length + const downloadSpinner = args.richUi ? spinner() : null + let lastPlainStatus = '' + + const emitStatus = (status: string): void => { + if (downloadSpinner) { + downloadSpinner.message(status) + return + } + if (status === lastPlainStatus) { + return + } + console.log(status) + lastPlainStatus = status + } + + if (downloadSpinner) { + downloadSpinner.start(`Preparing local speech models (${modelCount})...`) + } else { + log.message(`Preparing local speech models (${modelCount}): ${modelList}`) + } + + try { + for (const [index, modelId] of modelIds.entries()) { + const modelIndex = index + 1 + emitStatus(`Checking speech model ${modelIndex}/${modelCount}: ${modelId}`) + + const perModelLogger = createLocalSpeechDownloadLogger((event) => { + if (event.type === 'progress') { + const progress = event.progress + if (progress.modelId && progress.modelId !== modelId) { + return + } + emitStatus( + renderLocalModelProgress({ + modelId, + modelIndex, + modelCount, + pct: progress.pct, + }) + ) + return + } + + if (event.phase === 'extracting') { + emitStatus(`Extracting speech model ${modelIndex}/${modelCount}: ${modelId}`) + return + } + if (event.phase === 'verifying') { + emitStatus(`Verifying speech model ${modelIndex}/${modelCount}: ${modelId}`) + return + } + if (event.phase === 'finalizing') { + emitStatus(`Finalizing speech model ${modelIndex}/${modelCount}: ${modelId}`) + return + } + }) + + await ensureLocalSpeechModels({ + modelsDir: local.modelsDir, + modelIds: [modelId], + autoDownload: true, + logger: perModelLogger as any, + }) + + emitStatus(`Speech model ready ${modelIndex}/${modelCount}: ${modelId}`) + } + + if (downloadSpinner) { + downloadSpinner.stop(`Local speech models ready (${modelCount})`) + } else { + log.message(`Local speech models ready (${modelCount}): ${modelList}`) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (downloadSpinner) { + downloadSpinner.error(`Failed to prepare local speech models: ${message}`) + } else { + log.error(`Failed to prepare local speech models: ${message}`) + } + throw error + } +} + +function parseDownloadProgress(logTail: string): DownloadProgress | null { + const lines = logTail.split('\n').filter(Boolean) + + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index] + if (!line || !line.includes('Downloading model artifact')) { + continue + } + + const pctMatch = line.match(/"pct"\s*:\s*(\d{1,3})|\bpct[=:]\s*(\d{1,3})/) + const modelMatch = line.match( + /"modelId"\s*:\s*"([^"]+)"|\bmodelId[=:]\s*"?([^\s",}]+)/ + ) + + return { + modelId: modelMatch?.[1] ?? modelMatch?.[2] ?? null, + pct: pctMatch ? Number(pctMatch[1] ?? pctMatch[2]) : null, + } + } + + return null +} + +function renderProgressLine(progress: DownloadProgress): string { + const modelSuffix = progress.modelId ? ` (${progress.modelId})` : '' + if (progress.pct === null) { + return `Downloading speech model${modelSuffix}...` + } + return `Downloading speech model${modelSuffix}: ${progress.pct}%` +} + +async function waitForDaemonReady(args: { + home: string + timeoutMs: number + onStatus?: (message: string) => void +}): Promise<{ listen: string; host: string | null }> { + const deadline = Date.now() + args.timeoutMs + let lastStatus = '' + let lastPrintedAt = 0 + + while (Date.now() < deadline) { + const state = resolveLocalDaemonState({ home: args.home }) + const host = resolveTcpHostFromListen(state.listen) + + if (state.running && host) { + const client = await tryConnectToDaemon({ host, timeout: 1200 }) + if (client) { + try { + await client.fetchAgents() + return { listen: state.listen, host } + } catch { + // Daemon process is alive but not API-ready yet. + } finally { + await client.close().catch(() => {}) + } + } + } else if (state.running && !host) { + return { listen: state.listen, host: null } + } + + const progress = parseDownloadProgress(tailDaemonLog(args.home, 120) ?? '') + const progressLine = progress ? renderProgressLine(progress) : null + const statusMessage = progressLine ?? 'Waiting for daemon to become ready...' + + if (statusMessage !== lastStatus) { + args.onStatus?.(statusMessage) + lastStatus = statusMessage + lastPrintedAt = Date.now() + } else if (!args.onStatus && Date.now() - lastPrintedAt >= 3000) { + console.log(statusMessage) + lastPrintedAt = Date.now() + } + + await sleep(200) + } + + const recentLogs = tailDaemonLog(args.home, 60) + throw new Error( + [ + `Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`, + recentLogs ? `Recent daemon logs:\n${recentLogs}` : null, + ] + .filter(Boolean) + .join('\n\n') + ) +} + +function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: boolean): void { + const daemonLogPath = path.join(paseoHome, 'daemon.log') + const nextStepsLines = [ + pairingUrl + ? '1. Open Paseo and scan the QR code above, or paste the pairing link.' + : '1. Open Paseo and connect to your daemon.', + '2. Web app: https://app.paseo.sh', + '3. Desktop app: https://github.com/getpaseo/paseo/releases/latest', + '4. Docs: https://paseo.sh/docs', + ] + const quickReferenceLines = [ + '1. paseo --help', + '2. paseo ls', + '3. paseo run "your prompt"', + '4. paseo status', + `5. Daemon logs: ${daemonLogPath}`, + ] + + if (!richUi) { + console.log('') + console.log('Next steps:') + for (const line of nextStepsLines) { + console.log(line) + } + console.log('') + console.log('CLI quick reference:') + for (const line of quickReferenceLines) { + console.log(line) + } + return + } + + renderNote(nextStepsLines.join('\n'), 'Next steps') + renderNote(quickReferenceLines.join('\n'), 'CLI quick reference') +} + +export function onboardCommand(): Command { + return new Command('onboard') + .description('Run first-time setup, start daemon, and print pairing instructions') + .option('--listen ', 'Listen target (host:port, port, or unix socket path)') + .option('--port ', 'Port to listen on (default: 6767)') + .option('--home ', 'Paseo home directory (default: ~/.paseo)') + .option('--no-relay', 'Disable relay connection') + .option('--no-mcp', 'Disable the Agent MCP HTTP endpoint') + .option( + '--allowed-hosts ', + 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")' + ) + .option('--timeout ', 'Max time to wait for daemon readiness (default: 600)') + .option('--voice ', 'Voice setup mode: ask, enable, disable', 'ask') + .action(async (options: OnboardOptions) => { + await runOnboard(options) + }) +} + +export async function runOnboard(options: OnboardOptions): Promise { + const richUi = process.stdin.isTTY && process.stdout.isTTY + if (richUi) { + intro('Welcome to Paseo') + } + + if (options.listen && options.port) { + cancel('Cannot use --listen and --port together') + process.exit(1) + } + + let timeoutMs = DEFAULT_READY_TIMEOUT_MS + try { + timeoutMs = parseTimeoutMs(options.timeout) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + cancel(message) + process.exit(1) + } + + const paseoHome = resolveLocalPaseoHome(options.home) + if (richUi) { + renderNote(paseoHome, 'Paseo home') + } + + let persisted = loadPersistedConfig(paseoHome) as OnboardPersistedConfig + const persistedVoiceSelection = resolvePersistedVoiceSelection(persisted) + const shouldPrompt = options.voice === 'ask' || options.voice === undefined + let voiceEnabled: boolean + try { + voiceEnabled = + shouldPrompt && persistedVoiceSelection !== null + ? persistedVoiceSelection + : await resolveVoiceSelection(options.voice) + } catch (error) { + if (error instanceof OnboardCancelledError) { + cancel('Onboarding cancelled.') + process.exit(0) + return + } + throw error + } + + if (shouldPrompt && persistedVoiceSelection !== null) { + log.message(`Using saved voice setup from config (${voiceEnabled ? 'enabled' : 'disabled'}).`) + } + + persisted = applyVoiceSelection(persisted, voiceEnabled) + savePersistedConfig(paseoHome, persisted) + + const config = loadConfig(paseoHome, { cli: toCliOverrides(options) }) + + const voiceStatus = voiceEnabled + ? 'Voice features enabled. Local speech models will be downloaded if missing.' + : 'Voice features disabled. Local speech models will not be downloaded now.' + log.message(voiceStatus) + + try { + await prepareLocalSpeechModelsBeforeStart({ + config, + richUi, + }) + } catch { + process.exit(1) + } + + const stateBeforeStart = resolveLocalDaemonState({ home: options.home }) + const startSpinner = richUi ? spinner() : null + + if (!stateBeforeStart.running) { + try { + if (startSpinner) { + startSpinner.start('Starting daemon...') + } else { + log.message('Starting daemon...') + } + const startup = await startLocalDaemonDetached(options) + if (startSpinner) { + startSpinner.stop(`Daemon started (PID ${startup.pid ?? 'unknown'})`) + } else { + log.message(`Daemon started (PID ${startup.pid ?? 'unknown'})`) + } + log.message(`Logs: ${startup.logPath}`) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (startSpinner) { + startSpinner.error(message) + } else { + log.error(message) + } + process.exit(1) + } + } else { + log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? 'unknown'}).`) + } + + let readyState: { listen: string; host: string | null } + const readySpinner = richUi ? spinner() : null + try { + if (readySpinner) { + readySpinner.start('Waiting for daemon to become ready...') + } else { + log.message('Waiting for daemon to become ready...') + } + readyState = await waitForDaemonReady({ + home: options.home ?? paseoHome, + timeoutMs, + onStatus: readySpinner ? (message) => readySpinner.message(message) : undefined, + }) + if (readySpinner) { + readySpinner.stop(`Daemon ready on ${readyState.listen}`) + } else { + log.message(`Daemon ready on ${readyState.listen}`) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (readySpinner) { + readySpinner.error(message) + } else { + log.error(message) + } + process.exit(1) + return + } + + if (config.relayEnabled === false) { + log.warn('Relay is disabled; pairing offer is unavailable for this daemon.') + printNextSteps(null, paseoHome, richUi) + if (richUi) { + outro('Paseo daemon is running.') + } + return + } + + const pairing = await generateLocalPairingOffer({ + paseoHome, + relayEnabled: config.relayEnabled, + relayEndpoint: config.relayEndpoint, + relayPublicEndpoint: config.relayPublicEndpoint, + appBaseUrl: config.appBaseUrl, + includeQr: true, + }) + + if (!pairing.url) { + log.warn('Relay pairing URL is unavailable for this daemon configuration.') + printNextSteps(null, paseoHome, richUi) + if (richUi) { + outro('Paseo daemon is running.') + } + return + } + + renderNote( + pairing.qr ?? 'QR is unavailable in this terminal. Use the pairing link below.', + 'Scan to pair' + ) + renderNote(pairing.url, 'Pairing link') + printNextSteps(pairing.url, paseoHome, richUi) + if (richUi) { + outro('Paseo is ready!') + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a4ebb07e6..496d8d2b2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,6 +2,6 @@ import { createCli } from './cli.js' const program = createCli() if (process.argv.length <= 2) { - process.argv.push('start') + process.argv.push('onboard') } program.parse() diff --git a/packages/cli/tests/17-onboard.test.ts b/packages/cli/tests/17-onboard.test.ts new file mode 100644 index 000000000..95a4cbeb0 --- /dev/null +++ b/packages/cli/tests/17-onboard.test.ts @@ -0,0 +1,68 @@ +#!/usr/bin/env npx tsx + +import assert from 'node:assert' +import { readFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { $ } from 'zx' + +$.verbose = false + +function randomPort(): number { + return 10000 + Math.floor(Math.random() * 50000) +} + +console.log('=== Onboarding Command ===\n') + +const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-onboard-home-')) +const port = randomPort() + +try { + console.log('Test 1: `paseo` runs blocking onboarding and prints pairing info') + const onboard = + await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npm run -s cli --`.nothrow() + + assert.strictEqual(onboard.exitCode, 0, `onboard should succeed: ${onboard.stderr}`) + assert(onboard.stdout.includes('Scan to pair'), 'onboard output should include scan header') + assert(onboard.stdout.includes('Pairing link'), 'onboard output should include pairing link header') + assert(onboard.stdout.includes('#offer='), 'onboard output should include pairing offer URL') + assert(onboard.stdout.includes('CLI quick reference'), 'onboard output should include CLI quick reference') + assert(onboard.stdout.includes('paseo --help'), 'onboard output should include --help shortcut') + assert(onboard.stdout.includes('paseo ls'), 'onboard output should include ls shortcut') + assert(onboard.stdout.includes('paseo run "your prompt"'), 'onboard output should include run shortcut') + assert(onboard.stdout.includes('paseo status'), 'onboard output should include status shortcut') + assert(onboard.stdout.includes(join(paseoHome, 'daemon.log')), 'onboard output should include daemon log path') + + const status = + await $`PASEO_HOME=${paseoHome} npm run -s cli -- daemon status --home ${paseoHome}`.nothrow() + assert.strictEqual(status.exitCode, 0, `daemon status should succeed: ${status.stderr}`) + assert(status.stdout.includes('running'), 'daemon should be running when onboarding exits') + console.log('✓ onboarding prints pairing info and waits for daemon readiness\n') + + console.log('Test 2: non-interactive onboarding persists voice disabled config') + const configRaw = await readFile(join(paseoHome, 'config.json'), 'utf-8') + const config = JSON.parse(configRaw) as { + features?: { + dictation?: { enabled?: boolean } + voiceMode?: { enabled?: boolean } + } + providers?: { + local?: { autoDownload?: boolean } + } + } + + assert.strictEqual(config.features?.dictation?.enabled, false, 'dictation.enabled should be false') + assert.strictEqual(config.features?.voiceMode?.enabled, false, 'voiceMode.enabled should be false') + assert.strictEqual(config.providers?.local?.autoDownload, false, 'local.autoDownload should be false') + const daemonLog = await readFile(join(paseoHome, 'daemon.log'), 'utf-8') + assert( + !daemonLog.includes('Ensuring local speech models'), + 'daemon should not attempt local speech model setup when voice is disabled' + ) + console.log('✓ non-interactive run persisted voice disabled choices\n') +} finally { + await $`PASEO_HOME=${paseoHome} npm run -s cli -- daemon stop --home ${paseoHome} --force`.nothrow() + await rm(paseoHome, { recursive: true, force: true }) +} + +console.log('=== Onboarding tests passed ===') diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index d7af20621..b13ce5c21 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -1,7 +1,10 @@ import { readFileSync } from "node:fs"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest"; import { DaemonClient, type DaemonTransport } from "./daemon-client"; +expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf(); +expectTypeOf<"getHighlightedDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf(); + function createMockLogger() { return { debug: vi.fn(), diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 8c5691166..79efe75b7 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -15,9 +15,7 @@ import type { CreateAgentRequestMessage, FileDownloadTokenResponse, FileExplorerResponse, - GitDiffResponse, GitSetupOptions, - HighlightedDiffResponse, CheckoutStatusResponse, CheckoutCommitResponse, CheckoutMergeResponse, @@ -178,8 +176,6 @@ export type CreateAgentRequestOptions = { labels?: Record; } & AgentConfigOverrides; -type GitDiffPayload = GitDiffResponse["payload"]; -type HighlightedDiffPayload = HighlightedDiffResponse["payload"]; type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; type SubscribeCheckoutDiffPayload = Extract< SessionOutboundMessage, @@ -1907,60 +1903,6 @@ export class DaemonClient { }); } - async getGitDiff( - agentId: string, - requestId?: string - ): Promise { - const resolvedRequestId = this.createRequestId(requestId); - const message = SessionInboundMessageSchema.parse({ - type: "git_diff_request", - agentId, - requestId: resolvedRequestId, - }); - return this.sendRequest({ - requestId: resolvedRequestId, - message, - timeout: 10000, - options: { skipQueue: true }, - select: (msg) => { - if (msg.type !== "git_diff_response") { - return null; - } - if (msg.payload.requestId !== resolvedRequestId) { - return null; - } - return msg.payload; - }, - }); - } - - async getHighlightedDiff( - agentId: string, - requestId?: string - ): Promise { - const resolvedRequestId = this.createRequestId(requestId); - const message = SessionInboundMessageSchema.parse({ - type: "highlighted_diff_request", - agentId, - requestId: resolvedRequestId, - }); - return this.sendRequest({ - requestId: resolvedRequestId, - message, - timeout: 10000, - options: { skipQueue: true }, - select: (msg) => { - if (msg.type !== "highlighted_diff_response") { - return null; - } - if (msg.payload.requestId !== resolvedRequestId) { - return null; - } - return msg.payload; - }, - }); - } - async validateBranch( options: { cwd: string; branchName: string }, requestId?: string diff --git a/packages/server/src/server/agent/tts-manager.ts b/packages/server/src/server/agent/tts-manager.ts index 0d6060b87..223b03707 100644 --- a/packages/server/src/server/agent/tts-manager.ts +++ b/packages/server/src/server/agent/tts-manager.ts @@ -229,17 +229,6 @@ export class TTSManager { isVoiceMode, }, }); - this.logger.info( - { - audioId, - chunkId, - chunkIndex, - isLastChunk: next.done, - bytes: chunkBuffer.length, - isVoiceMode, - }, - "Emitted audio_output chunk to client" - ); chunkIndex += 1; @@ -298,15 +287,6 @@ export class TTSManager { } pending.pendingChunks = Math.max(0, pending.pendingChunks - 1); - this.logger.info( - { - chunkId, - audioId, - remainingPendingChunks: pending.pendingChunks, - streamEnded: pending.streamEnded, - }, - "Received audio playback confirmation from client" - ); if (pending.pendingChunks === 0 && pending.streamEnded) { pending.resolve(); diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 79f5b40a9..b1025770d 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -1012,63 +1012,11 @@ describe("daemon client E2E", () => { expect(checkoutStatus.isGit).toBe(true); expect(checkoutStatus.repoRoot).toContain(cwd); - const diffRequestId = `diff-${Date.now()}`; - const diffMessagePromise = waitForSignal(15000, (resolve) => { - const unsubscribeDiff = ctx.client.on("git_diff_response", (message) => { - if (message.type !== "git_diff_response") { - return; - } - if (message.payload.agentId !== agent.id) { - return; - } - if (message.payload.requestId !== diffRequestId) { - return; - } - resolve(message); - }); - return unsubscribeDiff; - }); - - const diffResult = await ctx.client.getGitDiff(agent.id, diffRequestId); - const diffMessage = await diffMessagePromise; + const diffResult = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" }); expect(diffResult.error).toBeNull(); - expect(diffResult.diff).toContain("test.txt"); - expect(diffResult.diff).toContain("-original content"); - expect(diffResult.diff).toContain("+modified content"); - expect(diffResult.requestId).toBe(diffRequestId); - expect(diffMessage.payload.agentId).toBe(agent.id); - expect(diffMessage.payload.requestId).toBe(diffRequestId); - - const highlightRequestId = `highlight-${Date.now()}`; - const highlightMessagePromise = waitForSignal(15000, (resolve) => { - const unsubscribeHighlight = ctx.client.on( - "highlighted_diff_response", - (message) => { - if (message.type !== "highlighted_diff_response") { - return; - } - if (message.payload.agentId !== agent.id) { - return; - } - if (message.payload.requestId !== highlightRequestId) { - return; - } - resolve(message); - } - ); - return unsubscribeHighlight; - }); - - const highlightResult = await ctx.client.getHighlightedDiff( - agent.id, - highlightRequestId - ); - const highlightMessage = await highlightMessagePromise; - expect(highlightResult.error).toBeNull(); - expect(Array.isArray(highlightResult.files)).toBe(true); - expect(highlightResult.requestId).toBe(highlightRequestId); - expect(highlightMessage.payload.agentId).toBe(agent.id); - expect(highlightMessage.payload.requestId).toBe(highlightRequestId); + expect(Array.isArray(diffResult.files)).toBe(true); + expect(diffResult.files.length).toBeGreaterThan(0); + expect(diffResult.files.some((file) => file.path === "test.txt")).toBe(true); const listRequestId = `list-${Date.now()}`; const listMessagePromise = waitForSignal(15000, (resolve) => { diff --git a/packages/server/src/server/daemon-e2e/git-diff-bottleneck.local.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-diff-bottleneck.local.e2e.test.ts new file mode 100644 index 000000000..a8302c55a --- /dev/null +++ b/packages/server/src/server/daemon-e2e/git-diff-bottleneck.local.e2e.test.ts @@ -0,0 +1,101 @@ +import { describe, test, expect } from "vitest"; +import { execSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import { createDaemonTestContext } from "../test-utils/index.js"; + +const RUN = process.env.PASEO_GIT_DIFF_BOTTLENECK_E2E === "1"; +const LARGE_CHANGESET_SIZE = Number.parseInt( + process.env.PASEO_GIT_DIFF_BOTTLENECK_FILE_COUNT ?? "1200", + 10 +); + +function tmpRepo(): string { + return mkdtempSync(path.join(tmpdir(), "paseo-git-diff-bottleneck-")); +} + +function initGitRepo(cwd: string): void { + execSync("git init -b main", { cwd, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" }); + execSync("git config user.name 'Test'", { cwd, stdio: "pipe" }); +} + +function seedLargeDirtyRepo(cwd: string, fileCount: number): void { + mkdirSync(path.join(cwd, "files"), { recursive: true }); + for (let i = 0; i < fileCount; i += 1) { + writeFileSync(path.join(cwd, "files", `f-${i}.txt`), `line ${i}\n`); + } + execSync("git add .", { cwd, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'init'", { + cwd, + stdio: "pipe", + }); + for (let i = 0; i < fileCount; i += 1) { + writeFileSync(path.join(cwd, "files", `f-${i}.txt`), `line ${i} changed\n`); + } + + // Explicit binary artifact to verify we do not diff binary contents. + writeFileSync(path.join(cwd, "blob.bin"), Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00, 0x7f])); +} + +const runDescribe = RUN ? describe : describe.skip; + +runDescribe("daemon E2E git diff bottleneck profiling", () => { + test( + "shows per-file git diff subprocess fanout and timeout pressure", + async () => { + const cwd = tmpRepo(); + + try { + initGitRepo(cwd); + seedLargeDirtyRepo(cwd, LARGE_CHANGESET_SIZE); + + const cliStart = performance.now(); + const cliDiff = execSync("git diff HEAD", { cwd, stdio: "pipe" }).toString(); + const cliMs = performance.now() - cliStart; + + const ctx = await createDaemonTestContext(); + try { + const checkoutStart = performance.now(); + const checkoutPayload = await ctx.client.getCheckoutDiff(cwd, { + mode: "uncommitted", + }); + const checkoutMs = performance.now() - checkoutStart; + + expect(checkoutPayload.error).toBeNull(); + expect(checkoutPayload.files.length).toBeGreaterThanOrEqual(LARGE_CHANGESET_SIZE); + + const binaryEntry = checkoutPayload.files.find((file) => file.path === "blob.bin"); + expect(binaryEntry).toBeTruthy(); + expect(binaryEntry?.status).toBe("binary"); + + // Keep this visible in test output for local bottleneck analysis. + console.info( + "[git-diff-bottleneck]", + JSON.stringify( + { + fileCount: LARGE_CHANGESET_SIZE, + cliMs: Math.round(cliMs), + cliDiffBytes: cliDiff.length, + checkoutMs: Math.round(checkoutMs), + checkoutFiles: checkoutPayload.files.length, + speedRatio: Number((checkoutMs / Math.max(cliMs, 1)).toFixed(2)), + }, + null, + 2 + ) + ); + + expect(checkoutMs).toBeLessThan(cliMs * 10); + } finally { + await ctx.cleanup(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 240000 + ); +}); diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index d9f4c7485..5db24dca9 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -110,7 +110,7 @@ describe("daemon E2E", () => { await ctx.cleanup(); }, 60000); - describe("getGitDiff", () => { + describe("getCheckoutDiff", () => { test( "returns diff for modified file in git repo", async () => { @@ -134,28 +134,12 @@ describe("daemon E2E", () => { // Modify the file (creates unstaged changes) writeFileSync(testFile, "modified content\n"); - // Create agent in the git repo - const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Git Diff Test", - }); - - expect(agent.id).toBeTruthy(); - expect(agent.status).toBe("idle"); - - // Get git diff - const result = await ctx.client.getGitDiff(agent.id); - - // Verify diff returned without error + const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" }); expect(result.error).toBeNull(); - expect(result.diff).toBeTruthy(); - expect(result.diff).toContain("test.txt"); - expect(result.diff).toContain("-original content"); - expect(result.diff).toContain("+modified content"); - - // Cleanup - await ctx.client.deleteAgent(agent.id); + expect(result.files.length).toBeGreaterThan(0); + const file = result.files.find((entry) => entry.path === "test.txt"); + expect(file).toBeTruthy(); + expect(file?.hunks.length).toBeGreaterThan(0); rmSync(cwd, { recursive: true, force: true }); }, 60000 // 1 minute timeout @@ -181,23 +165,11 @@ describe("daemon E2E", () => { stdio: "pipe", }); - // Create agent in the git repo (no modifications) - const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Git Diff Clean Test", - }); - - expect(agent.id).toBeTruthy(); - - // Get git diff - should be empty - const result = await ctx.client.getGitDiff(agent.id); + const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" }); expect(result.error).toBeNull(); - expect(result.diff).toBe(""); + expect(result.files).toEqual([]); - // Cleanup - await ctx.client.deleteAgent(agent.id); rmSync(cwd, { recursive: true, force: true }); }, 60000 // 1 minute timeout @@ -209,24 +181,12 @@ describe("daemon E2E", () => { const cwd = tmpCwd(); // Don't initialize git - just a regular directory - // Create agent in a non-git directory - const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Git Diff Non-Git Test", - }); + const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" }); - expect(agent.id).toBeTruthy(); - - // Get git diff - should return error - const result = await ctx.client.getGitDiff(agent.id); - - expect(result.diff).toBe(""); + expect(result.files).toEqual([]); expect(result.error).toBeTruthy(); - expect(result.error).toContain("git"); + expect(result.error?.code).toBe("NOT_GIT_REPO"); - // Cleanup - await ctx.client.deleteAgent(agent.id); rmSync(cwd, { recursive: true, force: true }); }, 60000 // 1 minute timeout diff --git a/packages/server/src/server/dictation/dictation-stream-manager.ts b/packages/server/src/server/dictation/dictation-stream-manager.ts index c1c0ae1cd..d32993c42 100644 --- a/packages/server/src/server/dictation/dictation-stream-manager.ts +++ b/packages/server/src/server/dictation/dictation-stream-manager.ts @@ -315,7 +315,7 @@ export class DictationStreamManager { state.bytesSinceCommit += resampled.length; state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled)); try { - this.maybeAutoCommitDictationSegment(params.dictationId, state); + this.maybeAutoCommitDictationSegment(state); } catch (error) { const message = error instanceof Error ? error.message : String(error); void this.failAndCleanupDictationStream(params.dictationId, message, true); @@ -482,7 +482,7 @@ export class DictationStreamManager { this.streams.delete(dictationId); } - private maybeAutoCommitDictationSegment(dictationId: string, state: DictationStreamState): void { + private maybeAutoCommitDictationSegment(state: DictationStreamState): void { if (state.finishRequested) { return; } @@ -490,29 +490,12 @@ export class DictationStreamManager { return; } if (state.peakSinceCommit < DICTATION_SILENCE_PEAK_THRESHOLD) { - this.logger.debug( - { - dictationId, - autoCommitBytes: state.autoCommitBytes, - bytesSinceCommit: state.bytesSinceCommit, - peakSinceCommit: state.peakSinceCommit, - }, - "Dictation auto-segment: clearing silence-only segment" - ); state.stt.clear(); state.bytesSinceCommit = 0; state.peakSinceCommit = 0; return; } - this.logger.debug( - { - dictationId, - autoCommitBytes: state.autoCommitBytes, - bytesSinceCommit: state.bytesSinceCommit, - }, - "Dictation auto-segment: committing buffered audio" - ); state.bytesSinceCommit = 0; state.peakSinceCommit = 0; state.stt.commit(); diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index f1ce98afd..75dd9fd87 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -6,6 +6,13 @@ export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js"; export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js"; export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js"; export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent } from "../client/daemon-client.js"; +export { + ensureLocalSpeechModels, + listLocalSpeechModels, + type LocalSpeechModelId, + type LocalSttModelId, + type LocalTtsModelId, +} from "./speech/providers/local/models.js"; // Agent SDK types for CLI commands export type { diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index cd4818a9c..c2c2b8bd9 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -41,6 +41,7 @@ const SpeechProviderIdSchema = z const FeatureDictationSchema = z .object({ + enabled: z.boolean().optional(), stt: z .object({ provider: SpeechProviderIdSchema.optional(), @@ -54,6 +55,7 @@ const FeatureDictationSchema = z const FeatureVoiceModeSchema = z .object({ + enabled: z.boolean().optional(), llm: z .object({ provider: z.enum(AGENT_PROVIDER_IDS as [string, ...string[]]).optional(), diff --git a/packages/server/src/server/relay-transport.test.ts b/packages/server/src/server/relay-transport.test.ts new file mode 100644 index 000000000..919161cab --- /dev/null +++ b/packages/server/src/server/relay-transport.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const wsMock = vi.hoisted(() => { + class MockWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + static instances: MockWebSocket[] = []; + + readonly url: string; + readonly options: unknown; + readyState = MockWebSocket.CONNECTING; + sent: string[] = []; + terminateCalls = 0; + private listeners = new Map void>>(); + + constructor(url: string, options?: unknown) { + this.url = url; + this.options = options; + MockWebSocket.instances.push(this); + } + + static reset() { + MockWebSocket.instances = []; + } + + on(event: string, listener: (...args: any[]) => void) { + const handlers = this.listeners.get(event) ?? []; + handlers.push(listener); + this.listeners.set(event, handlers); + return this; + } + + once(event: string, listener: (...args: any[]) => void) { + const wrapped = (...args: any[]) => { + this.off(event, wrapped); + listener(...args); + }; + return this.on(event, wrapped); + } + + close(code?: number, reason?: string) { + this.readyState = MockWebSocket.CLOSED; + this.emit("close", code ?? 1000, reason ?? ""); + } + + terminate() { + this.terminateCalls += 1; + this.readyState = MockWebSocket.CLOSED; + this.emit("close", 1006, ""); + } + + send(data: string) { + if (this.readyState !== MockWebSocket.OPEN) { + throw new Error(`WebSocket not open (readyState=${this.readyState})`); + } + this.sent.push(data); + } + + open() { + this.readyState = MockWebSocket.OPEN; + this.emit("open"); + } + + message(data: unknown) { + this.emit("message", data); + } + + error(err: unknown) { + this.emit("error", err); + } + + private off(event: string, listener: (...args: any[]) => void) { + const handlers = this.listeners.get(event) ?? []; + this.listeners.set( + event, + handlers.filter((handler) => handler !== listener) + ); + } + + private emit(event: string, ...args: any[]) { + const handlers = this.listeners.get(event) ?? []; + for (const handler of [...handlers]) { + handler(...args); + } + } + } + + return { MockWebSocket }; +}); + +vi.mock("ws", () => ({ default: wsMock.MockWebSocket })); + +import { startRelayTransport } from "./relay-transport"; + +function createMockLogger() { + const logger = { + child: vi.fn(() => logger), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + return logger; +} + +function hasLogMessage(mockFn: ReturnType, message: string): boolean { + return mockFn.mock.calls.some((call) => call.some((arg) => arg === message)); +} + +describe("relay-transport control lifecycle", () => { + const controllers: Array<{ stop: () => Promise }> = []; + const MockWebSocket = wsMock.MockWebSocket; + + beforeEach(() => { + MockWebSocket.reset(); + }); + + afterEach(async () => { + for (const controller of controllers) { + await controller.stop(); + } + controllers.length = 0; + vi.useRealTimers(); + }); + + test("logs relay_control_connected only after first valid control message", () => { + const logger = createMockLogger(); + const controller = startRelayTransport({ + logger: logger as any, + attachSocket: async () => {}, + relayEndpoint: "relay.paseo.sh:443", + serverId: "srv_test", + }); + controllers.push(controller); + + const control = MockWebSocket.instances[0]; + expect(control).toBeDefined(); + + control.open(); + expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(false); + expect(control.sent.length).toBeGreaterThan(0); + + control.message(JSON.stringify({ type: "pong", ts: Date.now() })); + expect(hasLogMessage(logger.info, "relay_control_connected")).toBe(true); + }); + + test("terminates and reconnects when control socket opens but never becomes ready", () => { + vi.useFakeTimers(); + const logger = createMockLogger(); + const controller = startRelayTransport({ + logger: logger as any, + attachSocket: async () => {}, + relayEndpoint: "relay.paseo.sh:443", + serverId: "srv_test", + }); + controllers.push(controller); + + const firstControl = MockWebSocket.instances[0]; + firstControl.open(); + + vi.advanceTimersByTime(8_000); + expect(hasLogMessage(logger.warn, "relay_control_ready_timeout_terminating")).toBe(true); + expect(firstControl.terminateCalls).toBe(1); + + vi.advanceTimersByTime(1_000); + expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2); + }); + + test("terminates stale control sockets in under one minute", () => { + vi.useFakeTimers(); + const logger = createMockLogger(); + const controller = startRelayTransport({ + logger: logger as any, + attachSocket: async () => {}, + relayEndpoint: "relay.paseo.sh:443", + serverId: "srv_test", + }); + controllers.push(controller); + + const control = MockWebSocket.instances[0]; + control.open(); + control.message(JSON.stringify({ type: "pong", ts: Date.now() })); + logger.warn.mockClear(); + + vi.advanceTimersByTime(40_000); + expect(hasLogMessage(logger.warn, "relay_control_stale_terminating")).toBe(true); + expect(control.terminateCalls).toBe(1); + }); +}); diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 2ae27d705..29eca1bfa 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -37,6 +37,10 @@ type ControlMessage = | { type: "ping" } | { type: "pong" }; +const CONTROL_PING_INTERVAL_MS = 10_000; +const CONTROL_STALE_TIMEOUT_MS = 30_000; +const CONTROL_READY_TIMEOUT_MS = 8_000; + function tryParseControlMessage(raw: unknown): ControlMessage | null { try { const text = @@ -76,7 +80,9 @@ export function startRelayTransport({ let reconnectAttempt = 0; const dataSockets = new Map(); // clientId -> ws let controlKeepaliveInterval: ReturnType | null = null; + let controlReadyTimeout: ReturnType | null = null; let controlLastSeenAt = 0; + let controlConnectionSeq = 0; const stop = async (): Promise => { stopped = true; @@ -88,6 +94,10 @@ export function startRelayTransport({ clearInterval(controlKeepaliveInterval); controlKeepaliveInterval = null; } + if (controlReadyTimeout) { + clearTimeout(controlReadyTimeout); + controlReadyTimeout = null; + } if (controlWs) { try { controlWs.close(); @@ -109,6 +119,7 @@ export function startRelayTransport({ const connectControl = (): void => { if (stopped) return; + const connectionId = ++controlConnectionSeq; const url = buildRelayWebSocketUrl({ endpoint: relayEndpoint, serverId, @@ -116,14 +127,46 @@ export function startRelayTransport({ }); const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false }); controlWs = socket; + let controlConnected = false; + + const markControlReady = () => { + if (controlWs !== socket) return; + if (controlConnected) return; + controlConnected = true; + reconnectAttempt = 0; + if (controlReadyTimeout) { + clearTimeout(controlReadyTimeout); + controlReadyTimeout = null; + } + relayLogger.info({ url, connectionId }, "relay_control_connected"); + }; socket.on("open", () => { - reconnectAttempt = 0; + if (controlWs !== socket) return; + controlLastSeenAt = Date.now(); if (controlKeepaliveInterval) { clearInterval(controlKeepaliveInterval); controlKeepaliveInterval = null; } + if (controlReadyTimeout) { + clearTimeout(controlReadyTimeout); + controlReadyTimeout = null; + } + controlReadyTimeout = setTimeout(() => { + if (stopped) return; + if (controlWs !== socket) return; + if (controlConnected) return; + relayLogger.warn( + { url, connectionId, waitedMs: CONTROL_READY_TIMEOUT_MS }, + "relay_control_ready_timeout_terminating" + ); + try { + socket.terminate(); + } catch { + // ignore + } + }, CONTROL_READY_TIMEOUT_MS); controlKeepaliveInterval = setInterval(() => { if (stopped) return; if (controlWs !== socket) return; @@ -133,8 +176,11 @@ export function startRelayTransport({ const staleForMs = now - controlLastSeenAt; // If the control socket is half-open or silently dropped, ws may never emit "close". // Use app-level ping/pong to detect staleness and force a reconnect. - if (staleForMs > 90_000) { - relayLogger.warn({ url, staleForMs }, "relay_control_stale_terminating"); + if (staleForMs > CONTROL_STALE_TIMEOUT_MS) { + relayLogger.warn( + { url, staleForMs, connectionId, staleTimeoutMs: CONTROL_STALE_TIMEOUT_MS }, + "relay_control_stale_terminating" + ); try { socket.terminate(); } catch { @@ -146,40 +192,58 @@ export function startRelayTransport({ try { socket.send(JSON.stringify({ type: "ping", ts: now })); } catch (error) { - relayLogger.warn({ err: error, url }, "relay_control_ping_send_failed"); + relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed"); try { socket.terminate(); } catch { // ignore } } - }, 20_000); - relayLogger.info({ url }, "relay_control_connected"); + }, CONTROL_PING_INTERVAL_MS); + try { + socket.send(JSON.stringify({ type: "ping", ts: Date.now() })); + } catch (error) { + relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed"); + try { + socket.terminate(); + } catch { + // ignore + } + } + relayLogger.debug({ url, connectionId }, "relay_control_open_waiting_for_ready"); }); socket.on("close", (code, reason) => { + if (controlWs !== socket) return; relayLogger.warn( - { code, reason: reason?.toString?.(), url }, + { code, reason: reason?.toString?.(), url, connectionId }, "relay_control_disconnected" ); - if (controlWs === socket) { - controlWs = null; - } + controlWs = null; if (controlKeepaliveInterval) { clearInterval(controlKeepaliveInterval); controlKeepaliveInterval = null; } + if (controlReadyTimeout) { + clearTimeout(controlReadyTimeout); + controlReadyTimeout = null; + } scheduleReconnect(); }); socket.on("error", (err) => { - relayLogger.warn({ err, url }, "relay_error"); + if (controlWs !== socket) return; + relayLogger.warn({ err, url, connectionId }, "relay_error"); // close event will schedule reconnect }); socket.on("message", (data) => { + if (controlWs !== socket) return; controlLastSeenAt = Date.now(); const msg = tryParseControlMessage(data); + if (msg) { + markControlReady(); + } if (!msg) return; if (msg.type === "ping") { try { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 5cd214840..d13b51398 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -26,7 +26,6 @@ import { type ProjectPlacementPayload, } from "./messages.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; -import { parseAndHighlightDiff, type ParsedDiffFile } from "./utils/diff-highlighter.js"; import { TTSManager } from "./agent/tts-manager.js"; import { STTManager } from "./agent/stt-manager.js"; import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js"; @@ -581,7 +580,7 @@ export class Session { void this.initializeAgentMcp(); this.subscribeToAgentEvents(); - this.sessionLogger.info("Session created"); + this.sessionLogger.trace("Session created"); } /** @@ -750,7 +749,7 @@ export class Session { this.agentTools = (await this.agentMcpClient.tools()) as ToolSet; const agentToolCount = Object.keys(this.agentTools ?? {}).length; - this.sessionLogger.info( + this.sessionLogger.trace( { agentToolCount }, `Agent MCP initialized with ${agentToolCount} tools` ); @@ -941,9 +940,17 @@ export class Session { agentId, extractTimestamps(record) ); + this.sessionLogger.info( + { agentId, provider: record.provider }, + "Agent resumed from persistence" + ); } else { const config = buildSessionConfig(record); snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels }); + this.sessionLogger.info( + { agentId, provider: record.provider }, + "Agent created from stored config" + ); } await this.agentManager.hydrateTimelineFromProvider(agentId); @@ -1215,10 +1222,6 @@ export class Session { ); break; - case "git_diff_request": - await this.handleGitDiffRequest(msg.agentId, msg.requestId); - break; - case "checkout_status_request": await this.handleCheckoutStatusRequest(msg); break; @@ -1267,10 +1270,6 @@ export class Session { await this.handlePaseoWorktreeArchiveRequest(msg); break; - case "highlighted_diff_request": - await this.handleHighlightedDiffRequest(msg.agentId, msg.requestId); - break; - case "file_explorer_request": await this.handleFileExplorerRequest(msg); break; @@ -2087,11 +2086,6 @@ export class Session { agentId: string, requestId: string ): Promise { - this.sessionLogger.info( - { agentId }, - `Initializing agent ${agentId} on demand` - ); - try { const snapshot = await this.ensureAgentLoaded(agentId); await this.forwardAgentUpdate(snapshot); @@ -2108,11 +2102,6 @@ export class Session { requestId, }, }); - - this.sessionLogger.info( - { agentId, timelineSize, status: snapshot.lifecycle }, - `Agent ${agentId} initialized with ${timelineSize} timeline item(s); status=${snapshot.lifecycle}` - ); } catch (error: any) { this.sessionLogger.error( { err: error, agentId }, @@ -3393,66 +3382,6 @@ export class Session { } } - /** - * Handle git diff request for an agent - */ - private async handleGitDiffRequest(agentId: string, requestId: string): Promise { - this.sessionLogger.debug( - { agentId }, - `Handling git diff request for agent ${agentId}` - ); - - try { - const agents = this.agentManager.listAgents(); - const agent = agents.find((a) => a.id === agentId); - - if (!agent) { - this.emit({ - type: "git_diff_response", - payload: { - agentId, - diff: "", - error: `Agent not found: ${agentId}`, - requestId, - }, - }); - return; - } - - const diffResult = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" }, { paseoHome: this.paseoHome }); - const combinedDiff = diffResult.diff; - - this.emit({ - type: "git_diff_response", - payload: { - agentId, - diff: combinedDiff, - error: null, - requestId, - }, - }); - - this.sessionLogger.debug( - { agentId, diffBytes: combinedDiff.length }, - `Git diff for agent ${agentId} completed (${combinedDiff.length} bytes)` - ); - } catch (error: any) { - this.sessionLogger.error( - { err: error, agentId }, - `Failed to get git diff for agent ${agentId}` - ); - this.emit({ - type: "git_diff_response", - payload: { - agentId, - diff: "", - error: error.message, - requestId, - }, - }); - } - } - private async handleCheckoutStatusRequest( msg: Extract ): Promise { @@ -4190,12 +4119,13 @@ export class Session { const { cwd, requestId } = msg; try { - const status = await getPullRequestStatus(cwd); + const prStatus = await getPullRequestStatus(cwd); this.emit({ type: "checkout_pr_status_response", payload: { cwd, - status, + status: prStatus.status, + githubFeaturesEnabled: prStatus.githubFeaturesEnabled, error: null, requestId, }, @@ -4206,6 +4136,7 @@ export class Session { payload: { cwd, status: null, + githubFeaturesEnabled: true, error: this.toCheckoutError(error), requestId, }, @@ -4373,237 +4304,6 @@ export class Session { } } - /** - * Handle highlighted diff request - returns parsed and syntax-highlighted diff - */ - private async handleHighlightedDiffRequest( - agentId: string, - requestId: string - ): Promise { - this.sessionLogger.debug( - { agentId }, - `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); - - if (!agent) { - this.emit({ - type: "highlighted_diff_response", - payload: { - agentId, - files: [], - error: `Agent not found: ${agentId}`, - requestId, - }, - }); - return; - } - - // 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 file statuses (A=added, D=deleted, M=modified) to detect deleted files - const { stdout: nameStatusOutput } = await execAsync( - "git diff --name-status HEAD", - { cwd: agent.cwd } - ); - const deletedFiles = new Set(); - const addedFiles = new Set(); - for (const line of nameStatusOutput.trim().split("\n").filter(Boolean)) { - const [status, ...pathParts] = line.split("\t"); - const path = pathParts.join("\t"); - if (status === "D") { - deletedFiles.add(path); - } else if (status === "A") { - addedFiles.add(path); - } - } - - // Parse numstat output: "additions\tdeletions\tfilepath" or "-\t-\tfilepath" for binary - interface FileStats { - path: string; - additions: number; - deletions: number; - isBinary: boolean; - isTracked: boolean; - isDeleted: boolean; - isNew: 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, - isDeleted: deletedFiles.has(path), - isNew: addedFiles.has(path), - }); - } - } - - // Step 2: Get untracked files - try { - const { stdout: untrackedFiles } = await execAsync( - "git ls-files --others --exclude-standard", - { cwd: agent.cwd } - ); - 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: numstatLine } = await execAsync( - `git diff --numstat --no-index /dev/null "${filePath}" || true`, - { cwd: agent.cwd } - ); - 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, - isDeleted: false, - isNew: true, - }); - } catch { - // If we can't determine, assume text and try to get it - fileStats.push({ - path: filePath, - additions: 0, - deletions: 0, - isBinary: false, - isTracked: false, - isDeleted: false, - isNew: true, - }); - } - } - } catch { - // Ignore errors getting untracked files - } - - // Step 3: Fetch diffs per-file, respecting limits - const allFiles: ParsedDiffFile[] = []; - - for (const stats of fileStats) { - const totalLines = stats.additions + stats.deletions; - - // Handle binary files - if (stats.isBinary) { - allFiles.push({ - path: stats.path, - isNew: stats.isNew, - isDeleted: stats.isDeleted, - 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.isNew, - isDeleted: stats.isDeleted, - 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.isNew, - isDeleted: stats.isDeleted, - additions: stats.additions, - deletions: stats.deletions, - hunks: [], - status: "ok", - }); - } - } - - this.emit({ - type: "highlighted_diff_response", - payload: { - agentId, - files: allFiles, - error: null, - requestId, - }, - }); - - this.sessionLogger.debug( - { agentId, fileCount: allFiles.length }, - `Highlighted diff for agent ${agentId} completed (${allFiles.length} files)` - ); - } catch (error: any) { - this.sessionLogger.error( - { err: error, agentId }, - `Failed to get highlighted diff for agent ${agentId}` - ); - this.emit({ - type: "highlighted_diff_response", - payload: { - agentId, - files: [], - error: error.message, - requestId, - }, - }); - } - } - /** * Handle read-only file explorer requests scoped to an agent's cwd */ @@ -5871,7 +5571,7 @@ export class Session { * Clean up session resources */ public async cleanup(): Promise { - this.sessionLogger.info("Cleaning up"); + this.sessionLogger.trace("Cleaning up"); if (this.unsubscribeAgentEvents) { this.unsubscribeAgentEvents(); diff --git a/packages/server/src/server/speech/providers/local/config.ts b/packages/server/src/server/speech/providers/local/config.ts index b70d0c6e2..e494b9842 100644 --- a/packages/server/src/server/speech/providers/local/config.ts +++ b/packages/server/src/server/speech/providers/local/config.ts @@ -73,9 +73,10 @@ const LocalSpeechResolutionSchema = z.object({ function persistedLocalFeatureModel( provider: RequestedSpeechProviders[keyof RequestedSpeechProviders]["provider"], + enabled: boolean | undefined, model: string | undefined ): string | undefined { - if (provider !== "local") { + if (provider !== "local" || enabled === false) { return undefined; } return model; @@ -87,9 +88,12 @@ function shouldIncludeLocalProviderConfig(params: { persisted: PersistedConfig; }): boolean { const localRequestedByFeature = - params.providers.dictationStt.provider === "local" || - params.providers.voiceStt.provider === "local" || - params.providers.voiceTts.provider === "local"; + (params.providers.dictationStt.enabled !== false && + params.providers.dictationStt.provider === "local") || + (params.providers.voiceStt.enabled !== false && + params.providers.voiceStt.provider === "local") || + (params.providers.voiceTts.enabled !== false && + params.providers.voiceTts.provider === "local"); return ( localRequestedByFeature || @@ -119,6 +123,7 @@ export function resolveLocalSpeechConfig(params: { params.env.PASEO_DICTATION_LOCAL_STT_MODEL ?? persistedLocalFeatureModel( params.providers.dictationStt.provider, + params.providers.dictationStt.enabled, params.persisted.features?.dictation?.stt?.model ) ?? DEFAULT_LOCAL_STT_MODEL, @@ -126,6 +131,7 @@ export function resolveLocalSpeechConfig(params: { params.env.PASEO_VOICE_LOCAL_STT_MODEL ?? persistedLocalFeatureModel( params.providers.voiceStt.provider, + params.providers.voiceStt.enabled, params.persisted.features?.voiceMode?.stt?.model ) ?? DEFAULT_LOCAL_STT_MODEL, @@ -133,6 +139,7 @@ export function resolveLocalSpeechConfig(params: { params.env.PASEO_VOICE_LOCAL_TTS_MODEL ?? persistedLocalFeatureModel( params.providers.voiceTts.provider, + params.providers.voiceTts.enabled, params.persisted.features?.voiceMode?.tts?.model ) ?? DEFAULT_LOCAL_TTS_MODEL, diff --git a/packages/server/src/server/speech/providers/local/runtime.ts b/packages/server/src/server/speech/providers/local/runtime.ts index 6b57a9f0c..41c559626 100644 --- a/packages/server/src/server/speech/providers/local/runtime.ts +++ b/packages/server/src/server/speech/providers/local/runtime.ts @@ -88,13 +88,22 @@ function computeRequiredLocalModelIds(params: { models: ResolvedLocalModels; }): LocalSpeechModelId[] { const ids = new Set(); - if (params.providers.dictationStt.provider === "local") { + if ( + params.providers.dictationStt.enabled !== false && + params.providers.dictationStt.provider === "local" + ) { ids.add(params.models.dictationLocalSttModel); } - if (params.providers.voiceStt.provider === "local") { + if ( + params.providers.voiceStt.enabled !== false && + params.providers.voiceStt.provider === "local" + ) { ids.add(params.models.voiceLocalSttModel); } - if (params.providers.voiceTts.provider === "local") { + if ( + params.providers.voiceTts.enabled !== false && + params.providers.voiceTts.provider === "local" + ) { ids.add(params.models.voiceLocalTtsModel); } return Array.from(ids); @@ -258,7 +267,7 @@ export async function initializeLocalSpeechServices(params: { } }; - if (providers.voiceStt.provider === "local") { + if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === "local") { if (!localConfig) { logger.warn( { configured: false }, @@ -274,7 +283,7 @@ export async function initializeLocalSpeechServices(params: { } } - if (providers.dictationStt.provider === "local") { + if (providers.dictationStt.enabled !== false && providers.dictationStt.provider === "local") { if (!localConfig) { logger.warn( { configured: false }, @@ -297,7 +306,7 @@ export async function initializeLocalSpeechServices(params: { } } - if (providers.voiceTts.provider === "local") { + if (providers.voiceTts.enabled !== false && providers.voiceTts.provider === "local") { if (!localConfig) { logger.warn( { configured: false }, diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts index ab898bfae..dc7127e34 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -53,4 +53,46 @@ describe("sherpa model downloader", () => { }) ).rejects.toThrow(/auto-download/i); }); + + test("ensureSherpaOnnxModel logs artifact download progress", async () => { + const modelsDir = makeTmpDir(); + const progressLogs: Array> = []; + + const loggerWithSpy = { + child: () => loggerWithSpy, + info: (obj?: unknown, msg?: string) => { + if (msg === "Downloading model artifact" && obj && typeof obj === "object") { + progressLogs.push(obj as Record); + } + }, + error: () => undefined, + } as unknown as pino.Logger; + + const originalFetch = globalThis.fetch; + const payload = Buffer.alloc(128 * 1024, 7); + const fetchMock = vi.fn(async () => { + return new Response(payload, { + status: 200, + headers: { "content-length": String(payload.length) }, + }); + }); + globalThis.fetch = fetchMock as typeof fetch; + + try { + await ensureSherpaOnnxModel({ + modelsDir, + modelId: "pocket-tts-onnx-int8", + autoDownload: true, + logger: loggerWithSpy, + }); + } finally { + globalThis.fetch = originalFetch; + } + + expect(fetchMock).toHaveBeenCalled(); + expect(progressLogs.length).toBeGreaterThan(0); + const final = progressLogs.at(-1); + expect(final?.modelId).toBe("pocket-tts-onnx-int8"); + expect(final?.pct).toBe(100); + }); }); diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts index 66298751d..1f0e3ac83 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts @@ -1,7 +1,7 @@ import { createWriteStream } from "node:fs"; import { mkdir, rename, rm, stat } from "node:fs/promises"; import path from "node:path"; -import { Readable } from "node:stream"; +import { Readable, Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; import { spawn } from "node:child_process"; import type pino from "pino"; @@ -39,7 +39,31 @@ async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Prom return true; } -async function downloadToFile(url: string, outputPath: string, logger: pino.Logger): Promise { +const UNKNOWN_SIZE_PROGRESS_BYTES_STEP = 5 * 1024 * 1024; +const UNKNOWN_SIZE_PROGRESS_MS_STEP = 1000; + +type DownloadToFileOptions = { + url: string; + outputPath: string; + logger: pino.Logger; + modelId: SherpaOnnxModelId; + artifact: string; +}; + +function parseContentLength(res: Response): number | null { + const raw = res.headers.get("content-length"); + if (!raw) { + return null; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +} + +async function downloadToFile(options: DownloadToFileOptions): Promise { + const { url, outputPath, logger, modelId, artifact } = options; const res = await fetch(url); if (!res.ok) { throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`); @@ -51,24 +75,68 @@ async function downloadToFile(url: string, outputPath: string, logger: pino.Logg const tmpPath = `${outputPath}.tmp-${Date.now()}`; await mkdir(path.dirname(outputPath), { recursive: true }); - const total = Number(res.headers.get("content-length") ?? "0"); - let downloaded = 0; - let lastLoggedBucket = -1; + const nodeStream = Readable.fromWeb(res.body as any); + const totalBytes = parseContentLength(res); - const nodeStream = Readable.fromWeb(res.body as any).on("data", (chunk: Buffer) => { - downloaded += chunk.length; - if (total > 0) { - const pct = Math.floor((downloaded / total) * 100); - const bucket = Math.min(100, Math.floor(pct / 10) * 10); - if (bucket >= 0 && bucket <= 100 && bucket !== lastLoggedBucket) { - lastLoggedBucket = bucket; - logger.info({ pct: bucket, downloaded, total }, "Downloading model artifact"); + let downloadedBytes = 0; + let lastLoggedPct = -1; + let lastLoggedBytes = 0; + let lastLoggedAt = 0; + + const logProgress = (force: boolean): void => { + const pct = + totalBytes && totalBytes > 0 + ? Math.min(100, Math.floor((downloadedBytes * 100) / totalBytes)) + : null; + + if (!force) { + if (pct !== null) { + if (pct <= lastLoggedPct) { + return; + } + } else { + const now = Date.now(); + const advancedBytes = downloadedBytes - lastLoggedBytes; + if (advancedBytes < UNKNOWN_SIZE_PROGRESS_BYTES_STEP && now - lastLoggedAt < UNKNOWN_SIZE_PROGRESS_MS_STEP) { + return; + } } } + + logger.info( + { + modelId, + artifact, + url, + downloadedBytes, + totalBytes, + pct, + }, + "Downloading model artifact" + ); + lastLoggedPct = pct ?? -1; + lastLoggedBytes = downloadedBytes; + lastLoggedAt = Date.now(); + }; + + logProgress(false); + + const progressStream = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + downloadedBytes += chunk.length; + logProgress(false); + callback(null, chunk); + }, }); - await pipeline(nodeStream, createWriteStream(tmpPath)); - await rename(tmpPath, outputPath); + try { + await pipeline(nodeStream, progressStream, createWriteStream(tmpPath)); + logProgress(true); + await rename(tmpPath, outputPath); + } catch (error) { + await rm(tmpPath, { force: true }).catch(() => undefined); + throw error; + } } async function extractTarArchive(archivePath: string, destDir: string): Promise { @@ -114,60 +182,103 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption ); } - if (spec.archiveUrl) { - logger.info({ modelsDir: options.modelsDir, url: spec.archiveUrl }, "Model files missing; downloading"); + logger.info({ modelsDir: options.modelsDir }, "Starting model download"); - const downloadsDir = path.join(options.modelsDir, ".downloads"); - const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname); - const archivePath = path.join(downloadsDir, archiveFilename); + try { + if (spec.archiveUrl) { + const downloadsDir = path.join(options.modelsDir, ".downloads"); + const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname); + const archivePath = path.join(downloadsDir, archiveFilename); - if (!(await isNonEmptyFile(archivePath))) { - await downloadToFile(spec.archiveUrl, archivePath, logger); - } else { - logger.info({ archivePath }, "Using cached archive"); - } - - await extractTarArchive(archivePath, options.modelsDir); - - if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) { - throw new Error( - `Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.` - ); - } - - try { - await rm(archivePath, { force: true }); - } catch { - // ignore - } - - logger.info({ modelDir }, "Model ready"); - return modelDir; - } - - if (spec.downloadFiles && spec.downloadFiles.length > 0) { - logger.info({ modelsDir: options.modelsDir, fileCount: spec.downloadFiles.length }, "Model files missing; downloading"); - await mkdir(modelDir, { recursive: true }); - - for (const file of spec.downloadFiles) { - const dst = path.join(modelDir, file.relPath); - if (await isNonEmptyFile(dst)) { - continue; + if (!(await isNonEmptyFile(archivePath))) { + await downloadToFile({ + url: spec.archiveUrl, + outputPath: archivePath, + logger, + modelId: options.modelId, + artifact: archiveFilename, + }); } - await downloadToFile(file.url, dst, logger); - } - if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) { - throw new Error( - `Downloaded files for ${options.modelId}, but required files are still missing in ${modelDir}.` + logger.info( + { + modelId: options.modelId, + archivePath, + modelDir, + }, + "Extracting model archive" ); + await extractTarArchive(archivePath, options.modelsDir); + + logger.info( + { + modelId: options.modelId, + modelDir, + }, + "Verifying downloaded model files" + ); + if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) { + throw new Error( + `Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.` + ); + } + + logger.info( + { + modelId: options.modelId, + archivePath, + }, + "Finalizing model artifacts" + ); + try { + await rm(archivePath, { force: true }); + } catch { + // ignore + } + + logger.info({ modelDir }, "Model download completed"); + return modelDir; } - logger.info({ modelDir }, "Model ready"); - return modelDir; - } + if (spec.downloadFiles && spec.downloadFiles.length > 0) { + await mkdir(modelDir, { recursive: true }); - throw new Error(`Model spec for ${options.modelId} has no archiveUrl or downloadFiles`); + for (const file of spec.downloadFiles) { + const dst = path.join(modelDir, file.relPath); + if (await isNonEmptyFile(dst)) { + continue; + } + await downloadToFile({ + url: file.url, + outputPath: dst, + logger, + modelId: options.modelId, + artifact: file.relPath, + }); + } + + logger.info( + { + modelId: options.modelId, + modelDir, + }, + "Verifying downloaded model files" + ); + if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) { + throw new Error( + `Downloaded files for ${options.modelId}, but required files are still missing in ${modelDir}.` + ); + } + + logger.info({ modelDir }, "Model download completed"); + return modelDir; + } + + throw new Error(`Model spec for ${options.modelId} has no archiveUrl or downloadFiles`); + } catch (error) { + logger.error({ err: error }, "Model download failed"); + throw error; + } } export async function ensureSherpaOnnxModels(options: { diff --git a/packages/server/src/server/speech/providers/openai/config.ts b/packages/server/src/server/speech/providers/openai/config.ts index b71b0ad09..af627d36e 100644 --- a/packages/server/src/server/speech/providers/openai/config.ts +++ b/packages/server/src/server/speech/providers/openai/config.ts @@ -74,27 +74,32 @@ export function resolveOpenAiSpeechConfig(params: { params.persisted.features?.dictation?.stt?.confidenceThreshold, sttModel: params.env.STT_MODEL ?? - (params.providers.voiceStt.provider === "openai" + (params.providers.voiceStt.enabled !== false && + params.providers.voiceStt.provider === "openai" ? params.persisted.features?.voiceMode?.stt?.model : undefined) ?? - (params.providers.dictationStt.provider === "openai" + (params.providers.dictationStt.enabled !== false && + params.providers.dictationStt.provider === "openai" ? params.persisted.features?.dictation?.stt?.model : undefined), ttsVoice: params.env.TTS_VOICE ?? - (params.providers.voiceTts.provider === "openai" + (params.providers.voiceTts.enabled !== false && + params.providers.voiceTts.provider === "openai" ? params.persisted.features?.voiceMode?.tts?.voice : undefined) ?? "alloy", ttsModel: params.env.TTS_MODEL ?? - (params.providers.voiceTts.provider === "openai" + (params.providers.voiceTts.enabled !== false && + params.providers.voiceTts.provider === "openai" ? params.persisted.features?.voiceMode?.tts?.model : undefined) ?? DEFAULT_OPENAI_TTS_MODEL, realtimeTranscriptionModel: params.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ?? - (params.providers.dictationStt.provider === "openai" + (params.providers.dictationStt.enabled !== false && + params.providers.dictationStt.provider === "openai" ? params.persisted.features?.dictation?.stt?.model : undefined) ?? DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL, diff --git a/packages/server/src/server/speech/providers/openai/runtime.ts b/packages/server/src/server/speech/providers/openai/runtime.ts index 64d29eaf6..f81e6c83e 100644 --- a/packages/server/src/server/speech/providers/openai/runtime.ts +++ b/packages/server/src/server/speech/providers/openai/runtime.ts @@ -60,13 +60,22 @@ export function validateOpenAiCredentialRequirements(params: { const openAiCredentials = resolveOpenAiCredentials(openaiConfig); const missingOpenAiCredentialsFor: string[] = []; - if (providers.voiceStt.provider === "openai" && !openAiCredentials.openaiSttApiKey) { + if ( + providers.voiceStt.enabled !== false && + providers.voiceStt.provider === "openai" && + !openAiCredentials.openaiSttApiKey + ) { missingOpenAiCredentialsFor.push("voice.stt"); } - if (providers.voiceTts.provider === "openai" && !openAiCredentials.openaiTtsApiKey) { + if ( + providers.voiceTts.enabled !== false && + providers.voiceTts.provider === "openai" && + !openAiCredentials.openaiTtsApiKey + ) { missingOpenAiCredentialsFor.push("voice.tts"); } if ( + providers.dictationStt.enabled !== false && providers.dictationStt.provider === "openai" && !openAiCredentials.openaiDictationApiKey ) { @@ -104,10 +113,18 @@ export function initializeOpenAiSpeechServices(params: { let ttsService = existing.ttsService; let dictationSttService = existing.dictationSttService; - const needsOpenAiStt = !sttService && providers.voiceStt.provider === "openai"; - const needsOpenAiTts = !ttsService && providers.voiceTts.provider === "openai"; + const needsOpenAiStt = + !sttService && + providers.voiceStt.enabled !== false && + providers.voiceStt.provider === "openai"; + const needsOpenAiTts = + !ttsService && + providers.voiceTts.enabled !== false && + providers.voiceTts.provider === "openai"; const needsOpenAiDictation = - !dictationSttService && providers.dictationStt.provider === "openai"; + !dictationSttService && + providers.dictationStt.enabled !== false && + providers.dictationStt.provider === "openai"; if ( (needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) && diff --git a/packages/server/src/server/speech/speech-config-resolver.test.ts b/packages/server/src/server/speech/speech-config-resolver.test.ts index 495f93708..5015cdb09 100644 --- a/packages/server/src/server/speech/speech-config-resolver.test.ts +++ b/packages/server/src/server/speech/speech-config-resolver.test.ts @@ -21,14 +21,17 @@ describe("resolveSpeechConfig", () => { expect(result.speech.providers.dictationStt).toEqual({ provider: "local", explicit: false, + enabled: true, }); expect(result.speech.providers.voiceStt).toEqual({ provider: "local", explicit: false, + enabled: true, }); expect(result.speech.providers.voiceTts).toEqual({ provider: "local", explicit: false, + enabled: true, }); expect(result.speech.local).toEqual({ modelsDir: path.join(paseoHome, "models", "local-speech"), @@ -91,14 +94,17 @@ describe("resolveSpeechConfig", () => { expect(result.speech.providers.dictationStt).toEqual({ provider: "local", explicit: true, + enabled: true, }); expect(result.speech.providers.voiceStt).toEqual({ provider: "openai", explicit: true, + enabled: true, }); expect(result.speech.providers.voiceTts).toEqual({ provider: "local", explicit: true, + enabled: true, }); expect(result.speech.local?.models.dictationStt).toBe("zipformer-bilingual-zh-en-2023-02-20"); expect(result.speech.local?.models.voiceStt).toBe("parakeet-tdt-0.6b-v3-int8"); @@ -127,4 +133,35 @@ describe("resolveSpeechConfig", () => { expect(result.speech.local?.models.voiceTts).toBe("kokoro-en-v0_19"); expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(0); }); + + test("respects disabled dictation and voice mode feature flags", () => { + const persisted = PersistedConfigSchema.parse({ + features: { + dictation: { enabled: false }, + voiceMode: { enabled: false }, + }, + }); + + const result = resolveSpeechConfig({ + paseoHome: "/tmp/paseo-home", + env: {} as NodeJS.ProcessEnv, + persisted, + }); + + expect(result.speech.providers.dictationStt).toEqual({ + provider: "local", + explicit: false, + enabled: false, + }); + expect(result.speech.providers.voiceStt).toEqual({ + provider: "local", + explicit: false, + enabled: false, + }); + expect(result.speech.providers.voiceTts).toEqual({ + provider: "local", + explicit: false, + enabled: false, + }); + }); }); diff --git a/packages/server/src/server/speech/speech-config-resolver.ts b/packages/server/src/server/speech/speech-config-resolver.ts index 0d374954a..cda874760 100644 --- a/packages/server/src/server/speech/speech-config-resolver.ts +++ b/packages/server/src/server/speech/speech-config-resolver.ts @@ -17,6 +17,25 @@ const OptionalSpeechProviderSchema = z .pipe(SpeechProviderIdSchema) .optional(); +const OptionalBooleanFlagSchema = z + .union([z.boolean(), z.string().trim().toLowerCase()]) + .optional() + .transform((value) => { + if (typeof value === "boolean") { + return value; + } + if (value === undefined) { + return undefined; + } + if (["1", "true", "yes", "y", "on"].includes(value)) { + return true; + } + if (["0", "false", "no", "n", "off"].includes(value)) { + return false; + } + return undefined; + }); + const RequestedSpeechProvidersSchema = z.object({ dictationStt: OptionalSpeechProviderSchema.default("local"), voiceStt: OptionalSpeechProviderSchema.default("local"), @@ -29,10 +48,12 @@ function resolveRequestedSpeechProviders(params: { }): RequestedSpeechProviders { const resolveFeatureProvider = ( configuredValue: string | undefined, - parsedValue: z.infer + parsedValue: z.infer, + enabled: boolean ): RequestedSpeechProvider => ({ provider: parsedValue, explicit: configuredValue !== undefined, + enabled, }); const dictationSttProviderFromConfig = @@ -44,6 +65,14 @@ function resolveRequestedSpeechProviders(params: { const voiceTtsProviderFromConfig = params.env.PASEO_VOICE_TTS_PROVIDER ?? params.persisted.features?.voiceMode?.tts?.provider; + const dictationEnabled = + OptionalBooleanFlagSchema.parse( + params.env.PASEO_DICTATION_ENABLED ?? params.persisted.features?.dictation?.enabled + ) ?? true; + const voiceModeEnabled = + OptionalBooleanFlagSchema.parse( + params.env.PASEO_VOICE_MODE_ENABLED ?? params.persisted.features?.voiceMode?.enabled + ) ?? true; const parsed = RequestedSpeechProvidersSchema.parse({ dictationStt: dictationSttProviderFromConfig ?? "local", @@ -54,15 +83,18 @@ function resolveRequestedSpeechProviders(params: { return { dictationStt: resolveFeatureProvider( dictationSttProviderFromConfig, - parsed.dictationStt + parsed.dictationStt, + dictationEnabled ), voiceStt: resolveFeatureProvider( voiceSttProviderFromConfig, - parsed.voiceStt + parsed.voiceStt, + voiceModeEnabled ), voiceTts: resolveFeatureProvider( voiceTtsProviderFromConfig, - parsed.voiceTts + parsed.voiceTts, + voiceModeEnabled ), }; } diff --git a/packages/server/src/server/speech/speech-runtime.ts b/packages/server/src/server/speech/speech-runtime.ts index 9b1280cbd..dbd35e3a8 100644 --- a/packages/server/src/server/speech/speech-runtime.ts +++ b/packages/server/src/server/speech/speech-runtime.ts @@ -23,9 +23,9 @@ function resolveRequestedSpeechProviders( } return { - dictationStt: { provider: "local", explicit: false }, - voiceStt: { provider: "local", explicit: false }, - voiceTts: { provider: "local", explicit: false }, + dictationStt: { provider: "local", explicit: false, enabled: true }, + voiceStt: { provider: "local", explicit: false, enabled: true }, + voiceTts: { provider: "local", explicit: false, enabled: true }, }; } @@ -59,9 +59,18 @@ export async function initializeSpeechRuntime(params: { logger.info( { requestedProviders: { - dictationStt: providers.dictationStt.provider, - voiceStt: providers.voiceStt.provider, - voiceTts: providers.voiceTts.provider, + dictationStt: { + provider: providers.dictationStt.provider, + enabled: providers.dictationStt.enabled !== false, + }, + voiceStt: { + provider: providers.voiceStt.provider, + enabled: providers.voiceStt.enabled !== false, + }, + voiceTts: { + provider: providers.voiceTts.provider, + enabled: providers.voiceTts.enabled !== false, + }, }, availability: { openai: getOpenAiSpeechAvailability(openaiConfig), @@ -99,9 +108,11 @@ export async function initializeSpeechRuntime(params: { : "openai", }; const unavailableFeatures = [ - !openAiSpeech.dictationSttService ? "dictation.stt" : null, - !openAiSpeech.sttService ? "voice.stt" : null, - !openAiSpeech.ttsService ? "voice.tts" : null, + providers.dictationStt.enabled !== false && !openAiSpeech.dictationSttService + ? "dictation.stt" + : null, + providers.voiceStt.enabled !== false && !openAiSpeech.sttService ? "voice.stt" : null, + providers.voiceTts.enabled !== false && !openAiSpeech.ttsService ? "voice.tts" : null, ].filter((feature): feature is string => feature !== null); const explicitlyConfiguredUnavailableFeatures = unavailableFeatures.filter((feature) => { if (feature === "dictation.stt") { @@ -117,9 +128,18 @@ export async function initializeSpeechRuntime(params: { logger.error( { requestedProviders: { - dictationStt: providers.dictationStt.provider, - voiceStt: providers.voiceStt.provider, - voiceTts: providers.voiceTts.provider, + dictationStt: { + provider: providers.dictationStt.provider, + enabled: providers.dictationStt.enabled !== false, + }, + voiceStt: { + provider: providers.voiceStt.provider, + enabled: providers.voiceStt.enabled !== false, + }, + voiceTts: { + provider: providers.voiceTts.provider, + enabled: providers.voiceTts.enabled !== false, + }, }, explicitProviders: { dictationStt: providers.dictationStt.explicit, @@ -140,9 +160,18 @@ export async function initializeSpeechRuntime(params: { logger.warn( { requestedProviders: { - dictationStt: providers.dictationStt.provider, - voiceStt: providers.voiceStt.provider, - voiceTts: providers.voiceTts.provider, + dictationStt: { + provider: providers.dictationStt.provider, + enabled: providers.dictationStt.enabled !== false, + }, + voiceStt: { + provider: providers.voiceStt.provider, + enabled: providers.voiceStt.enabled !== false, + }, + voiceTts: { + provider: providers.voiceTts.provider, + enabled: providers.voiceTts.enabled !== false, + }, }, explicitProviders: { dictationStt: providers.dictationStt.explicit, diff --git a/packages/server/src/server/speech/speech-types.ts b/packages/server/src/server/speech/speech-types.ts index d9606addc..1d280f15d 100644 --- a/packages/server/src/server/speech/speech-types.ts +++ b/packages/server/src/server/speech/speech-types.ts @@ -6,6 +6,7 @@ export type SpeechProviderId = z.infer; export const RequestedSpeechProviderSchema = z.object({ provider: SpeechProviderIdSchema, explicit: z.boolean(), + enabled: z.boolean().optional(), }); export type RequestedSpeechProvider = z.infer; diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 940524a65..14284da92 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -279,7 +279,7 @@ export class VoiceAssistantWebSocketServer { }) ); - connectionLogger.info( + connectionLogger.trace( { clientId, totalSessions: this.sessions.size }, "Client connected" ); @@ -319,7 +319,7 @@ export class VoiceAssistantWebSocketServer { const session = this.sessions.get(ws); if (!session) return; - connectionLogger.info( + connectionLogger.trace( { clientId, totalSessions: this.sessions.size - 1 }, "Client disconnected" ); @@ -386,28 +386,12 @@ export class VoiceAssistantWebSocketServer { const message = parsedMessage.data; - const messageSummary = { - type: message.type, - ...(message.type === "session" && message.message - ? { sessionMessageType: message.message.type } - : {}), - }; - const isSessionNoise = - message.type === "session" && - (message.message.type === "client_heartbeat" || - message.message.type === "voice_audio_chunk" || - message.message.type === "dictation_stream_chunk"); - if (!isSessionNoise) { - this.logger.debug(messageSummary, "Received message"); - } - if (message.type === "ping") { this.sendToClient(ws, { type: "pong" }); return; } if (message.type === "recording_state") { - this.logger.debug({ isRecording: message.isRecording }, "Recording state"); return; } @@ -418,17 +402,6 @@ export class VoiceAssistantWebSocketServer { } if (message.type === "session") { - if (message.message.type === "create_agent_request") { - this.logger.debug( - { - cwd: message.message.config.cwd, - initialMode: message.message.config.modeId, - worktreeName: message.message.worktreeName, - requestId: message.message.requestId, - }, - "create_agent_request details" - ); - } await session.handleMessage(message.message); } } catch (error) { @@ -502,23 +475,11 @@ export class VoiceAssistantWebSocketServer { } { const activity = session.getClientActivity(); if (!activity) { - this.logger.debug("getClientActivityState: no activity for session"); return { deviceType: null, focusedAgentId: null, isStale: true, appVisible: false }; } const now = Date.now(); const ageMs = now - activity.lastActivityAt.getTime(); const isStale = ageMs >= this.ACTIVITY_THRESHOLD_MS; - this.logger.debug( - { - deviceType: activity.deviceType, - focusedAgentId: activity.focusedAgentId, - lastActivityAt: activity.lastActivityAt.toISOString(), - ageMs, - isStale, - appVisible: activity.appVisible, - }, - "getClientActivityState" - ); return { deviceType: activity.deviceType, focusedAgentId: activity.focusedAgentId, @@ -603,16 +564,6 @@ export class VoiceAssistantWebSocketServer { const allStates = clientEntries.map((e) => e.state); - this.logger.debug( - { - agentId: params.agentId, - reason: params.reason, - clientCount: clientEntries.length, - allStates, - }, - "broadcastAgentAttention" - ); - const hasActiveWebClient = allStates.some( (state) => state.deviceType === "web" && !state.isStale ); @@ -627,11 +578,6 @@ export class VoiceAssistantWebSocketServer { !hasActiveWebClient && !hasActiveMobileForegroundClient; - this.logger.debug( - { hasActiveWebClient, hasActiveMobileForegroundClient, shouldSendPush }, - "Push gating check" - ); - if (shouldSendPush) { const tokens = this.pushTokenStore.getAllTokens(); this.logger.info({ tokenCount: tokens.length }, "Sending push notification"); diff --git a/packages/server/src/shared/messages.stream-parsing.test.ts b/packages/server/src/shared/messages.stream-parsing.test.ts index 1b477867e..6c5c5af85 100644 --- a/packages/server/src/shared/messages.stream-parsing.test.ts +++ b/packages/server/src/shared/messages.stream-parsing.test.ts @@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest"; import { AgentStreamMessageSchema, AgentStreamSnapshotMessageSchema, + SessionInboundMessageSchema, + SessionOutboundMessageSchema, WSOutboundMessageSchema, } from "./messages.js"; @@ -64,4 +66,44 @@ describe("shared messages stream parsing", () => { }); expect(wrapped.success).toBe(false); }); + + it("rejects removed legacy git diff request messages", () => { + const gitDiffParsed = SessionInboundMessageSchema.safeParse({ + type: "git_diff_request", + agentId: "agent-1", + requestId: "req-1", + }); + expect(gitDiffParsed.success).toBe(false); + + const highlightedParsed = SessionInboundMessageSchema.safeParse({ + type: "highlighted_diff_request", + agentId: "agent-1", + requestId: "req-2", + }); + expect(highlightedParsed.success).toBe(false); + }); + + it("rejects removed legacy git diff response messages", () => { + const gitDiffParsed = SessionOutboundMessageSchema.safeParse({ + type: "git_diff_response", + payload: { + agentId: "agent-1", + diff: "", + error: null, + requestId: "req-1", + }, + }); + expect(gitDiffParsed.success).toBe(false); + + const highlightedParsed = SessionOutboundMessageSchema.safeParse({ + type: "highlighted_diff_response", + payload: { + agentId: "agent-1", + files: [], + error: null, + requestId: "req-2", + }, + }); + expect(highlightedParsed.success).toBe(false); + }); }); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index ac8eddb09..869ba67ca 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -696,12 +696,6 @@ export const AgentPermissionResponseMessageSchema = z.object({ response: AgentPermissionResponseSchema, }); -export const GitDiffRequestSchema = z.object({ - type: z.literal("git_diff_request"), - agentId: z.string(), - requestId: z.string(), -}); - const CheckoutErrorCodeSchema = z.enum([ "NOT_GIT_REPO", "NOT_ALLOWED", @@ -837,12 +831,6 @@ const ParsedDiffFileSchema = z.object({ status: z.enum(["ok", "too_large", "binary"]).optional(), }); -export const HighlightedDiffRequestSchema = z.object({ - type: z.literal("highlighted_diff_request"), - agentId: z.string(), - requestId: z.string(), -}); - const FileExplorerEntrySchema = z.object({ name: z.string(), path: z.string(), @@ -1010,7 +998,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SetAgentModelRequestMessageSchema, SetAgentThinkingRequestMessageSchema, AgentPermissionResponseMessageSchema, - GitDiffRequestSchema, CheckoutStatusRequestSchema, SubscribeCheckoutDiffRequestSchema, UnsubscribeCheckoutDiffRequestSchema, @@ -1023,7 +1010,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ValidateBranchRequestSchema, PaseoWorktreeListRequestSchema, PaseoWorktreeArchiveRequestSchema, - HighlightedDiffRequestSchema, FileExplorerRequestSchema, ProjectIconRequestSchema, FileDownloadTokenRequestSchema, @@ -1411,16 +1397,6 @@ export const AgentArchivedMessageSchema = z.object({ }), }); -export const GitDiffResponseSchema = z.object({ - type: z.literal("git_diff_response"), - payload: z.object({ - agentId: z.string(), - diff: z.string(), - error: z.string().nullable(), - requestId: z.string(), - }), -}); - const AheadBehindSchema = z.object({ ahead: z.number(), behind: z.number(), @@ -1564,6 +1540,7 @@ export const CheckoutPrStatusResponseSchema = z.object({ payload: z.object({ cwd: z.string(), status: CheckoutPrStatusSchema.nullable(), + githubFeaturesEnabled: z.boolean(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), }), @@ -1605,16 +1582,6 @@ export const PaseoWorktreeArchiveResponseSchema = z.object({ }), }); -export const HighlightedDiffResponseSchema = z.object({ - type: z.literal("highlighted_diff_response"), - payload: z.object({ - agentId: z.string(), - files: z.array(ParsedDiffFileSchema), - error: z.string().nullable(), - requestId: z.string(), - }), -}); - export const FileExplorerResponseSchema = z.object({ type: z.literal("file_explorer_response"), payload: z.object({ @@ -1834,7 +1801,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentPermissionResolvedMessageSchema, AgentDeletedMessageSchema, AgentArchivedMessageSchema, - GitDiffResponseSchema, CheckoutStatusResponseSchema, SubscribeCheckoutDiffResponseSchema, CheckoutDiffUpdateSchema, @@ -1847,7 +1813,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ValidateBranchResponseSchema, PaseoWorktreeListResponseSchema, PaseoWorktreeArchiveResponseSchema, - HighlightedDiffResponseSchema, FileExplorerResponseSchema, ProjectIconResponseSchema, FileDownloadTokenResponseSchema, @@ -1944,8 +1909,6 @@ export type SetAgentModeRequestMessage = z.infer; export type SetAgentThinkingRequestMessage = z.infer; export type AgentPermissionResponseMessage = z.infer; -export type GitDiffRequest = z.infer; -export type GitDiffResponse = z.infer; export type CheckoutStatusRequest = z.infer; export type CheckoutStatusResponse = z.infer; export type SubscribeCheckoutDiffRequest = z.infer< @@ -1976,8 +1939,6 @@ export type PaseoWorktreeListRequest = z.infer; export type PaseoWorktreeArchiveRequest = z.infer; export type PaseoWorktreeArchiveResponse = z.infer; -export type HighlightedDiffRequest = z.infer; -export type HighlightedDiffResponse = z.infer; export type FileExplorerRequest = z.infer; export type FileExplorerResponse = z.infer; export type ProjectIconRequest = z.infer; diff --git a/packages/server/src/utils/checkout-git-batching.test.ts b/packages/server/src/utils/checkout-git-batching.test.ts new file mode 100644 index 000000000..7ef40a386 --- /dev/null +++ b/packages/server/src/utils/checkout-git-batching.test.ts @@ -0,0 +1,82 @@ +import { execSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; + +const spawnCounters = vi.hoisted(() => ({ + trackedTextDiffCalls: 0, +})); + +vi.mock("child_process", async () => { + const actual = await vi.importActual("child_process"); + return { + ...actual, + spawn: (...args: Parameters) => { + const [command, commandArgs] = args; + if (command === "git" && Array.isArray(commandArgs)) { + const normalizedArgs = commandArgs.map((arg) => String(arg)); + const isTrackedTextDiff = + normalizedArgs[0] === "diff" && + normalizedArgs.includes("HEAD") && + !normalizedArgs.includes("--numstat") && + !normalizedArgs.includes("--no-index"); + if (isTrackedTextDiff) { + spawnCounters.trackedTextDiffCalls += 1; + } + } + return actual.spawn(...args); + }, + }; +}); + +import { getCheckoutDiff } from "./checkout-git.js"; + +function initRepoWithTrackedChanges(fileCount: number): { tempDir: string; repoDir: string } { + const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-batch-test-"))); + const repoDir = join(tempDir, "repo"); + + execSync(`mkdir -p ${repoDir}`); + execSync("git init -b main", { cwd: repoDir }); + execSync("git config user.email 'test@test.com'", { cwd: repoDir }); + execSync("git config user.name 'Test'", { cwd: repoDir }); + + for (let i = 0; i < fileCount; i += 1) { + writeFileSync(join(repoDir, `file-${i}.txt`), `before-${i}\n`); + } + execSync("git add .", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir }); + + for (let i = 0; i < fileCount; i += 1) { + writeFileSync(join(repoDir, `file-${i}.txt`), `after-${i}\n`); + } + + return { tempDir, repoDir }; +} + +describe("checkout git diff batching", () => { + let tempDir: string; + let repoDir: string; + + beforeEach(() => { + const setup = initRepoWithTrackedChanges(20); + tempDir = setup.tempDir; + repoDir = setup.repoDir; + spawnCounters.trackedTextDiffCalls = 0; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("uses a single tracked git diff command for tracked file diffs", async () => { + const result = await getCheckoutDiff(repoDir, { + mode: "uncommitted", + includeStructured: false, + }); + + expect(result.diff).toContain("file-0.txt"); + expect(result.diff).toContain("file-19.txt"); + expect(spawnCounters.trackedTextDiffCalls).toBe(1); + }); +}); diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 139b4eb53..f6d4acfa3 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -1,11 +1,12 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { execSync } from "child_process"; -import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "fs"; +import { mkdtempSync, rmSync, writeFileSync, realpathSync, mkdirSync, symlinkSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; import { commitAll, getCheckoutDiff, + getPullRequestStatus, getCheckoutStatus, getCheckoutStatusLite, mergeToBase, @@ -141,6 +142,27 @@ describe("checkout git utilities", () => { ); }); + it("short-circuits tracked binary files", async () => { + const trackedBinaryPath = join(repoDir, "tracked-blob.bin"); + writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00])); + execSync("git add tracked-blob.bin", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'add tracked binary'", { + cwd: repoDir, + }); + + writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x11, 0x81, 0x00])); + + const diff = await getCheckoutDiff(repoDir, { + mode: "uncommitted", + includeStructured: true, + }); + + const entry = diff.structured?.find((file) => file.path === "tracked-blob.bin"); + expect(entry).toBeTruthy(); + expect(entry?.status).toBe("binary"); + expect(diff.diff).toContain("# tracked-blob.bin: binary diff omitted"); + }); + it("short-circuits untracked binary files", async () => { const binaryPath = join(repoDir, "blob.bin"); writeFileSync(binaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00, 0x7f, 0x00])); @@ -380,6 +402,29 @@ describe("checkout git utilities", () => { execSync(`git --git-dir ${remoteDir} show-ref --verify refs/heads/feature`); }); + it("disables GitHub features when gh is unavailable", async () => { + execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir }); + + const fakeBinDir = join(tempDir, "fake-bin"); + mkdirSync(fakeBinDir); + const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim(); + symlinkSync(gitPath, join(fakeBinDir, "git")); + + const originalPath = process.env.PATH; + process.env.PATH = fakeBinDir; + try { + const status = await getPullRequestStatus(repoDir); + expect(status.githubFeaturesEnabled).toBe(false); + expect(status.status).toBeNull(); + } finally { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + } + }); + it("returns typed MergeConflictError on merge conflicts", async () => { const conflictFile = join(repoDir, "conflict.txt"); writeFileSync(conflictFile, "base\n"); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 49536eef1..1792621dc 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -184,35 +184,81 @@ async function tryResolveMergeBase(cwd: string, baseRef: string): Promise { - try { - const { text } = await spawnLimitedText({ - cmd: "git", - args, - cwd, - env: READ_ONLY_GIT_ENV, - maxBytes: 64 * 1024, - acceptExitCodes: [0], - }); - const line = text.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; +function normalizeNumstatPath(pathField: string): string { + const braceRenameMatch = pathField.match(/^(.*)\{(.*) => (.*)\}(.*)$/); + if (braceRenameMatch) { + const [, prefix, , renamed, suffix] = braceRenameMatch; + return `${prefix}${renamed}${suffix}`; } + + const inlineRenameMatch = pathField.match(/^(.*) => (.*)$/); + if (inlineRenameMatch) { + return inlineRenameMatch[2] ?? pathField; + } + + return pathField; +} + +const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB +const TRACKED_MAX_CHANGED_LINES = 40_000; + +async function getTrackedNumstatByPath( + cwd: string, + ref: string +): Promise> { + const result = await spawnLimitedText({ + cmd: "git", + args: ["diff", "--numstat", ref], + cwd, + env: READ_ONLY_GIT_ENV, + maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES, + acceptExitCodes: [0], + }); + + const stats = new Map(); + const lines = result.text + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + for (const line of lines) { + const parts = line.split("\t"); + if (parts.length < 3) { + continue; + } + + const additionsField = parts[0] ?? ""; + const deletionsField = parts[1] ?? ""; + const rawPath = parts.slice(2).join("\t"); + const path = normalizeNumstatPath(rawPath); + + if (!path) { + continue; + } + + if (additionsField === "-" || deletionsField === "-") { + stats.set(path, { additions: 0, deletions: 0, isBinary: true }); + continue; + } + + const additions = Number.parseInt(additionsField, 10); + const deletions = Number.parseInt(deletionsField, 10); + if (Number.isNaN(additions) || Number.isNaN(deletions)) { + stats.set(path, null); + continue; + } + + stats.set(path, { additions, deletions, isBinary: false }); + } + + return stats; +} + +function isTrackedDiffTooLarge(stat: FileStat): boolean { + if (!stat || stat.isBinary) { + return false; + } + return stat.additions + stat.deletions > TRACKED_MAX_CHANGED_LINES; } export class NotGitRepoError extends Error { @@ -784,49 +830,32 @@ function buildPlaceholderParsedDiffFile( }; } -async function getPerFileDiffText( +async function getUntrackedDiffText( cwd: string, - ref: string, change: CheckoutFileChange ): Promise<{ text: string; truncated: boolean; stat: FileStat }> { - if (change.isUntracked) { - try { - const inspected = await inspectUntrackedFile(cwd, change.path); - if (inspected.stat?.isBinary || inspected.truncated) { - return { text: "", truncated: inspected.truncated, stat: inspected.stat }; - } - } catch { - // Fall through to git diff path if metadata probing fails. + try { + const inspected = await inspectUntrackedFile(cwd, change.path); + if (inspected.stat?.isBinary || inspected.truncated) { + return { text: "", truncated: inspected.truncated, stat: inspected.stat }; } - - 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: { additions: 0, deletions: 0, isBinary: false }, - }; - } - - const stat = await tryGetNumstat(cwd, ["diff", "--numstat", ref, "--", change.path]); - if (stat?.isBinary) { - return { text: "", truncated: false, stat }; + } catch { + // Fall through to git diff path if metadata probing fails. } const result = await spawnLimitedText({ cmd: "git", - args: ["diff", ref, "--", change.path], + 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 }; + return { + text: result.text, + truncated: result.truncated, + stat: { additions: 0, deletions: 0, isBinary: false }, + }; } export async function getCheckoutStatus( @@ -966,8 +995,111 @@ export async function getCheckoutDiff( } }; - for (const change of changes) { - const { text, truncated, stat } = await getPerFileDiffText(cwd, refForDiff, change); + const trackedChanges = changes.filter((change) => !change.isUntracked); + const untrackedChanges = changes.filter((change) => change.isUntracked === true); + + const trackedNumstatByPath = + trackedChanges.length > 0 ? await getTrackedNumstatByPath(cwd, refForDiff) : new Map(); + const trackedDiffPaths: string[] = []; + const trackedPlaceholderByPath = new Map< + string, + { status: "binary" | "too_large"; stat: FileStat } + >(); + + for (const change of trackedChanges) { + const stat = trackedNumstatByPath.get(change.path) ?? null; + if (stat?.isBinary) { + trackedPlaceholderByPath.set(change.path, { status: "binary", stat }); + continue; + } + if (isTrackedDiffTooLarge(stat)) { + trackedPlaceholderByPath.set(change.path, { status: "too_large", stat }); + continue; + } + trackedDiffPaths.push(change.path); + } + + let trackedDiffText = ""; + let trackedDiffTruncated = false; + if (trackedDiffPaths.length > 0) { + const trackedDiffResult = await spawnLimitedText({ + cmd: "git", + args: ["diff", refForDiff, "--", ...trackedDiffPaths], + cwd, + env: READ_ONLY_GIT_ENV, + maxBytes: TOTAL_DIFF_MAX_BYTES, + }); + trackedDiffText = trackedDiffResult.text; + trackedDiffTruncated = trackedDiffResult.truncated; + appendDiff(trackedDiffText); + if (trackedDiffTruncated) { + appendDiff("# tracked diff truncated\n"); + } + } + + const appendTrackedPlaceholderComment = (change: CheckoutFileChange, status: "binary" | "too_large") => { + if (status === "binary") { + appendDiff(`# ${change.path}: binary diff omitted\n`); + return; + } + appendDiff(`# ${change.path}: diff too large omitted\n`); + }; + + if (compare.includeStructured) { + const parsedTrackedFiles = + trackedDiffText.length > 0 ? await parseAndHighlightDiff(trackedDiffText, cwd) : []; + const parsedTrackedByPath = new Map(parsedTrackedFiles.map((file) => [file.path, file])); + + for (const change of trackedChanges) { + const placeholder = trackedPlaceholderByPath.get(change.path); + if (placeholder) { + structured.push( + buildPlaceholderParsedDiffFile(change, { + status: placeholder.status, + stat: placeholder.stat, + }) + ); + appendTrackedPlaceholderComment(change, placeholder.status); + continue; + } + + const stat = trackedNumstatByPath.get(change.path) ?? null; + const parsedFile = parsedTrackedByPath.get(change.path); + if (parsedFile) { + structured.push({ + ...parsedFile, + path: change.path, + isNew: change.isNew, + isDeleted: change.isDeleted, + status: "ok", + }); + continue; + } + + structured.push({ + path: change.path, + isNew: change.isNew, + isDeleted: change.isDeleted, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + hunks: [], + status: trackedDiffTruncated ? "too_large" : "ok", + }); + } + } else { + for (const change of trackedChanges) { + const placeholder = trackedPlaceholderByPath.get(change.path); + if (placeholder) { + appendTrackedPlaceholderComment(change, placeholder.status); + } + } + } + + for (const change of untrackedChanges) { + if (diffBytes >= TOTAL_DIFF_MAX_BYTES) { + break; + } + const { text, truncated, stat } = await getUntrackedDiffText(cwd, change); if (!compare.includeStructured) { if (stat?.isBinary) { @@ -977,9 +1109,6 @@ export async function getCheckoutDiff( } else { appendDiff(text); } - if (diffBytes >= TOTAL_DIFF_MAX_BYTES) { - break; - } continue; } @@ -1268,6 +1397,11 @@ export interface PullRequestStatus { headRefName: string; } +export interface PullRequestStatusResult { + status: PullRequestStatus | null; + githubFeaturesEnabled: boolean; +} + async function ensureGhAvailable(cwd: string): Promise { try { await execAsync("gh --version", { cwd }); @@ -1276,6 +1410,27 @@ async function ensureGhAvailable(cwd: string): Promise { } } +function getCommandErrorText(error: unknown): string { + if (!(error instanceof Error)) { + return String(error); + } + const stderr = typeof (error as any)?.stderr === "string" ? (error as any).stderr : ""; + const stdout = typeof (error as any)?.stdout === "string" ? (error as any).stdout : ""; + return `${error.message}\n${stderr}\n${stdout}`.toLowerCase(); +} + +function isGhAuthError(error: unknown): boolean { + const text = getCommandErrorText(error); + return ( + text.includes("gh auth login") || + text.includes("not logged into any github hosts") || + text.includes("authentication failed") || + text.includes("authentication required") || + text.includes("bad credentials") || + text.includes("http 401") + ); +} + async function resolveGitHubRepo(cwd: string): Promise { try { const { stdout } = await execAsync("git config --get remote.origin.url", { @@ -1356,39 +1511,66 @@ export async function createPullRequest( return { url: parsed.url, number: parsed.number }; } -export async function getPullRequestStatus(cwd: string): Promise { +export async function getPullRequestStatus(cwd: string): Promise { await requireGitRepo(cwd); - await ensureGhAvailable(cwd); const repo = await resolveGitHubRepo(cwd); const head = await getCurrentBranch(cwd); if (!repo || !head) { - return null; + return { + status: null, + githubFeaturesEnabled: false, + }; + } + try { + await ensureGhAvailable(cwd); + } catch { + return { + status: null, + githubFeaturesEnabled: false, + }; } const owner = repo.split("/")[0]; - const { stdout } = await execFileAsync( - "gh", - [ - "api", - `repos/${repo}/pulls`, - "-X", - "GET", - "-F", - `head=${owner}:${head}`, - "-F", - "state=open", - ], - { cwd } - ); + let stdout: string; + try { + ({ stdout } = await execFileAsync( + "gh", + [ + "api", + `repos/${repo}/pulls`, + "-X", + "GET", + "-F", + `head=${owner}:${head}`, + "-F", + "state=open", + ], + { cwd } + )); + } catch (error) { + if (isGhAuthError(error)) { + return { + status: null, + githubFeaturesEnabled: false, + }; + } + throw error; + } const parsed = JSON.parse(stdout.trim()); const current = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null; if (!current) { - return null; + return { + status: null, + githubFeaturesEnabled: true, + }; } return { - url: current.html_url ?? current.url, - title: current.title, - state: current.state, - baseRefName: current.base?.ref ?? "", - headRefName: current.head?.ref ?? head, + status: { + url: current.html_url ?? current.url, + title: current.title, + state: current.state, + baseRefName: current.base?.ref ?? "", + headRefName: current.head?.ref ?? head, + }, + githubFeaturesEnabled: true, }; }