feat: add onboarding command and migrate git diff to checkout API

This commit is contained in:
Mohamed Boudra
2026-02-12 17:00:35 +07:00
parent 44c32fb8fb
commit 018edef3bb
39 changed files with 2038 additions and 965 deletions

22
package-lock.json generated
View File

@@ -1385,6 +1385,27 @@
"react-native": "*" "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": { "node_modules/@cloudflare/kv-asset-handler": {
"version": "0.4.1", "version": "0.4.1",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
@@ -19899,6 +19920,7 @@
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.2", "version": "0.1.2",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.2", "@getpaseo/relay": "0.1.2",
"@getpaseo/server": "0.1.2", "@getpaseo/server": "0.1.2",
"chalk": "^5.3.0", "chalk": "^5.3.0",

View File

@@ -506,6 +506,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
}); });
const { const {
status: prStatus, status: prStatus,
githubFeaturesEnabled,
payloadError: prPayloadError, payloadError: prPayloadError,
refresh: refreshPrStatus, refresh: refreshPrStatus,
} = useCheckoutPrStatusQuery({ } = useCheckoutPrStatusQuery({
@@ -840,7 +841,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const diffErrorMessage = const diffErrorMessage =
diffPayloadError?.message ?? diffPayloadError?.message ??
(isDiffError && diffError instanceof Error ? diffError.message : null); (isDiffError && diffError instanceof Error ? diffError.message : null);
const prErrorMessage = prPayloadError?.message ?? null; const prErrorMessage = githubFeaturesEnabled ? prPayloadError?.message ?? null : null;
const branchLabel = const branchLabel =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
? gitStatus.currentBranch ? gitStatus.currentBranch
@@ -993,7 +994,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
} }
// View PR - when PR exists // View PR - when PR exists
if (hasPullRequest && prStatus?.url) { if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
const prUrl = prStatus.url; const prUrl = prStatus.url;
allActions.set("view-pr", { allActions.set("view-pr", {
id: "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 // Create PR - when ahead of base and no PR
if (aheadCount > 0 && !hasPullRequest) { if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
allActions.set("create-pr", { allActions.set("create-pr", {
id: "create-pr", id: "create-pr",
label: "Create PR", label: "Create PR",
@@ -1112,7 +1113,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
return { primary, secondary, menu }; 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, hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled, commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus, commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,

View File

@@ -322,7 +322,6 @@ export function SessionProvider({
const setPendingPermissions = useSessionStore( const setPendingPermissions = useSessionStore(
(state) => state.setPendingPermissions (state) => state.setPendingPermissions
); );
const setGitDiffs = useSessionStore((state) => state.setGitDiffs);
const setFileExplorer = useSessionStore((state) => state.setFileExplorer); const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
const clearDraftInput = useDraftStore((state) => state.clearDraftInput); const clearDraftInput = useDraftStore((state) => state.clearDraftInput);
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
@@ -666,22 +665,6 @@ export function SessionProvider({
[serverId, setFileExplorer] [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({ const refreshAgentMutation = useMutation({
mutationFn: async ({ agentId }: { agentId: string }) => { mutationFn: async ({ agentId }: { agentId: string }) => {
if (!agentId) { if (!agentId) {
@@ -1275,15 +1258,6 @@ export function SessionProvider({
return next; return next;
}); });
setGitDiffs(serverId, (prev) => {
if (!prev.has(agentId)) {
return prev;
}
const next = new Map(prev);
next.delete(agentId);
return next;
});
setFileExplorer(serverId, (prev) => { setFileExplorer(serverId, (prev) => {
if (!prev.has(agentId)) { if (!prev.has(agentId)) {
return prev; return prev;
@@ -1343,7 +1317,6 @@ export function SessionProvider({
setAgents, setAgents,
setAgentLastActivity, setAgentLastActivity,
setPendingPermissions, setPendingPermissions,
setGitDiffs,
setFileExplorer, setFileExplorer,
setHasHydratedAgents, setHasHydratedAgents,
updateConnectionStatus, 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( const requestDirectoryListing = useCallback(
(agentId: string, path: string, options?: { recordHistory?: boolean }) => { (agentId: string, path: string, options?: { recordHistory?: boolean }) => {
const normalizedPath = path && path.length > 0 ? path : "."; const normalizedPath = path && path.length > 0 ? path : ".";

View File

@@ -43,6 +43,7 @@ export function useCheckoutPrStatusQuery({
return { return {
status: query.data?.status ?? null, status: query.data?.status ?? null,
githubFeaturesEnabled: query.data?.githubFeaturesEnabled ?? true,
payloadError: query.data?.error ?? null, payloadError: query.data?.error ?? null,
isLoading: query.isLoading, isLoading: query.isLoading,
isFetching: query.isFetching, isFetching: query.isFetching,

View File

@@ -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,
};
}

View File

@@ -201,9 +201,6 @@ export interface SessionState {
// Permissions // Permissions
pendingPermissions: Map<string, PendingPermission>; pendingPermissions: Map<string, PendingPermission>;
// Git diffs
gitDiffs: Map<string, string>;
// File explorer // File explorer
fileExplorer: Map<string, AgentFileExplorerState>; fileExplorer: Map<string, AgentFileExplorerState>;
@@ -258,9 +255,6 @@ interface SessionStoreActions {
// Permissions // Permissions
setPendingPermissions: (serverId: string, perms: Map<string, PendingPermission> | ((prev: Map<string, PendingPermission>) => Map<string, PendingPermission>)) => void; setPendingPermissions: (serverId: string, perms: Map<string, PendingPermission> | ((prev: Map<string, PendingPermission>) => Map<string, PendingPermission>)) => void;
// Git diffs
setGitDiffs: (serverId: string, diffs: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => void;
// File explorer // File explorer
setFileExplorer: (serverId: string, state: Map<string, AgentFileExplorerState> | ((prev: Map<string, AgentFileExplorerState>) => Map<string, AgentFileExplorerState>)) => void; setFileExplorer: (serverId: string, state: Map<string, AgentFileExplorerState> | ((prev: Map<string, AgentFileExplorerState>) => Map<string, AgentFileExplorerState>)) => void;
@@ -332,7 +326,6 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio
initializingAgents: new Map(), initializingAgents: new Map(),
agents: new Map(), agents: new Map(),
pendingPermissions: new Map(), pendingPermissions: new Map(),
gitDiffs: new Map(),
fileExplorer: new Map(), fileExplorer: new Map(),
queuedMessages: new Map(), queuedMessages: new Map(),
}; };
@@ -745,28 +738,6 @@ export const useSessionStore = create<SessionStore>()(
}); });
}, },
// 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 // File explorer
setFileExplorer: (serverId, state) => { setFileExplorer: (serverId, state) => {
set((prev) => { set((prev) => {

View File

@@ -21,6 +21,7 @@
"test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts" "test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts"
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.2", "@getpaseo/relay": "0.1.2",
"@getpaseo/server": "0.1.2", "@getpaseo/server": "0.1.2",
"chalk": "^5.3.0", "chalk": "^5.3.0",

View File

@@ -18,6 +18,7 @@ import { runWaitCommand } from './commands/agent/wait.js'
import { runAttachCommand } from './commands/agent/attach.js' import { runAttachCommand } from './commands/agent/attach.js'
import { runUpdateCommand } from './commands/agent/update.js' import { runUpdateCommand } from './commands/agent/update.js'
import { withOutput } from './output/index.js' import { withOutput } from './output/index.js'
import { onboardCommand } from './commands/onboard.js'
const VERSION = '0.1.0' const VERSION = '0.1.0'
@@ -142,6 +143,7 @@ export function createCli(): Command {
.action(withOutput(runUpdateCommand)) .action(withOutput(runUpdateCommand))
// Top-level local daemon shortcuts // Top-level local daemon shortcuts
program.addCommand(onboardCommand())
program.addCommand(daemonStartCommand()) program.addCommand(daemonStartCommand())
program program

View File

@@ -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<void> {
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<boolean> {
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<string, unknown>) => 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<typeof loadConfig>): LocalSpeechModelId[] {
const providers = config.speech?.providers
const local = config.speech?.local
if (!providers || !local) {
return []
}
const ids = new Set<LocalSpeechModelId>()
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<string, unknown>
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<typeof loadConfig>
richUi: boolean
}): Promise<void> {
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>', 'Listen target (host:port, port, or unix socket path)')
.option('--port <port>', 'Port to listen on (default: 6767)')
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--no-relay', 'Disable relay connection')
.option('--no-mcp', 'Disable the Agent MCP HTTP endpoint')
.option(
'--allowed-hosts <hosts>',
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
)
.option('--timeout <seconds>', 'Max time to wait for daemon readiness (default: 600)')
.option('--voice <mode>', 'Voice setup mode: ask, enable, disable', 'ask')
.action(async (options: OnboardOptions) => {
await runOnboard(options)
})
}
export async function runOnboard(options: OnboardOptions): Promise<void> {
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!')
}
}

View File

@@ -2,6 +2,6 @@ import { createCli } from './cli.js'
const program = createCli() const program = createCli()
if (process.argv.length <= 2) { if (process.argv.length <= 2) {
process.argv.push('start') process.argv.push('onboard')
} }
program.parse() program.parse()

View File

@@ -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 ===')

View File

@@ -1,7 +1,10 @@
import { readFileSync } from "node:fs"; 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"; import { DaemonClient, type DaemonTransport } from "./daemon-client";
expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
expectTypeOf<"getHighlightedDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
function createMockLogger() { function createMockLogger() {
return { return {
debug: vi.fn(), debug: vi.fn(),

View File

@@ -15,9 +15,7 @@ import type {
CreateAgentRequestMessage, CreateAgentRequestMessage,
FileDownloadTokenResponse, FileDownloadTokenResponse,
FileExplorerResponse, FileExplorerResponse,
GitDiffResponse,
GitSetupOptions, GitSetupOptions,
HighlightedDiffResponse,
CheckoutStatusResponse, CheckoutStatusResponse,
CheckoutCommitResponse, CheckoutCommitResponse,
CheckoutMergeResponse, CheckoutMergeResponse,
@@ -178,8 +176,6 @@ export type CreateAgentRequestOptions = {
labels?: Record<string, string>; labels?: Record<string, string>;
} & AgentConfigOverrides; } & AgentConfigOverrides;
type GitDiffPayload = GitDiffResponse["payload"];
type HighlightedDiffPayload = HighlightedDiffResponse["payload"];
type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
type SubscribeCheckoutDiffPayload = Extract< type SubscribeCheckoutDiffPayload = Extract<
SessionOutboundMessage, SessionOutboundMessage,
@@ -1907,60 +1903,6 @@ export class DaemonClient {
}); });
} }
async getGitDiff(
agentId: string,
requestId?: string
): Promise<GitDiffPayload> {
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<HighlightedDiffPayload> {
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( async validateBranch(
options: { cwd: string; branchName: string }, options: { cwd: string; branchName: string },
requestId?: string requestId?: string

View File

@@ -229,17 +229,6 @@ export class TTSManager {
isVoiceMode, isVoiceMode,
}, },
}); });
this.logger.info(
{
audioId,
chunkId,
chunkIndex,
isLastChunk: next.done,
bytes: chunkBuffer.length,
isVoiceMode,
},
"Emitted audio_output chunk to client"
);
chunkIndex += 1; chunkIndex += 1;
@@ -298,15 +287,6 @@ export class TTSManager {
} }
pending.pendingChunks = Math.max(0, pending.pendingChunks - 1); 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) { if (pending.pendingChunks === 0 && pending.streamEnded) {
pending.resolve(); pending.resolve();

View File

@@ -1012,63 +1012,11 @@ describe("daemon client E2E", () => {
expect(checkoutStatus.isGit).toBe(true); expect(checkoutStatus.isGit).toBe(true);
expect(checkoutStatus.repoRoot).toContain(cwd); expect(checkoutStatus.repoRoot).toContain(cwd);
const diffRequestId = `diff-${Date.now()}`; const diffResult = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
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;
expect(diffResult.error).toBeNull(); expect(diffResult.error).toBeNull();
expect(diffResult.diff).toContain("test.txt"); expect(Array.isArray(diffResult.files)).toBe(true);
expect(diffResult.diff).toContain("-original content"); expect(diffResult.files.length).toBeGreaterThan(0);
expect(diffResult.diff).toContain("+modified content"); expect(diffResult.files.some((file) => file.path === "test.txt")).toBe(true);
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);
const listRequestId = `list-${Date.now()}`; const listRequestId = `list-${Date.now()}`;
const listMessagePromise = waitForSignal(15000, (resolve) => { const listMessagePromise = waitForSignal(15000, (resolve) => {

View File

@@ -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
);
});

View File

@@ -110,7 +110,7 @@ describe("daemon E2E", () => {
await ctx.cleanup(); await ctx.cleanup();
}, 60000); }, 60000);
describe("getGitDiff", () => { describe("getCheckoutDiff", () => {
test( test(
"returns diff for modified file in git repo", "returns diff for modified file in git repo",
async () => { async () => {
@@ -134,28 +134,12 @@ describe("daemon E2E", () => {
// Modify the file (creates unstaged changes) // Modify the file (creates unstaged changes)
writeFileSync(testFile, "modified content\n"); writeFileSync(testFile, "modified content\n");
// Create agent in the git repo const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
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
expect(result.error).toBeNull(); expect(result.error).toBeNull();
expect(result.diff).toBeTruthy(); expect(result.files.length).toBeGreaterThan(0);
expect(result.diff).toContain("test.txt"); const file = result.files.find((entry) => entry.path === "test.txt");
expect(result.diff).toContain("-original content"); expect(file).toBeTruthy();
expect(result.diff).toContain("+modified content"); expect(file?.hunks.length).toBeGreaterThan(0);
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}, },
60000 // 1 minute timeout 60000 // 1 minute timeout
@@ -181,23 +165,11 @@ describe("daemon E2E", () => {
stdio: "pipe", stdio: "pipe",
}); });
// Create agent in the git repo (no modifications) const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
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);
expect(result.error).toBeNull(); 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 }); rmSync(cwd, { recursive: true, force: true });
}, },
60000 // 1 minute timeout 60000 // 1 minute timeout
@@ -209,24 +181,12 @@ describe("daemon E2E", () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
// Don't initialize git - just a regular directory // Don't initialize git - just a regular directory
// Create agent in a non-git directory const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
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",
});
expect(agent.id).toBeTruthy(); expect(result.files).toEqual([]);
// Get git diff - should return error
const result = await ctx.client.getGitDiff(agent.id);
expect(result.diff).toBe("");
expect(result.error).toBeTruthy(); 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 }); rmSync(cwd, { recursive: true, force: true });
}, },
60000 // 1 minute timeout 60000 // 1 minute timeout

View File

@@ -315,7 +315,7 @@ export class DictationStreamManager {
state.bytesSinceCommit += resampled.length; state.bytesSinceCommit += resampled.length;
state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled)); state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled));
try { try {
this.maybeAutoCommitDictationSegment(params.dictationId, state); this.maybeAutoCommitDictationSegment(state);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
void this.failAndCleanupDictationStream(params.dictationId, message, true); void this.failAndCleanupDictationStream(params.dictationId, message, true);
@@ -482,7 +482,7 @@ export class DictationStreamManager {
this.streams.delete(dictationId); this.streams.delete(dictationId);
} }
private maybeAutoCommitDictationSegment(dictationId: string, state: DictationStreamState): void { private maybeAutoCommitDictationSegment(state: DictationStreamState): void {
if (state.finishRequested) { if (state.finishRequested) {
return; return;
} }
@@ -490,29 +490,12 @@ export class DictationStreamManager {
return; return;
} }
if (state.peakSinceCommit < DICTATION_SILENCE_PEAK_THRESHOLD) { 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.stt.clear();
state.bytesSinceCommit = 0; state.bytesSinceCommit = 0;
state.peakSinceCommit = 0; state.peakSinceCommit = 0;
return; return;
} }
this.logger.debug(
{
dictationId,
autoCommitBytes: state.autoCommitBytes,
bytesSinceCommit: state.bytesSinceCommit,
},
"Dictation auto-segment: committing buffered audio"
);
state.bytesSinceCommit = 0; state.bytesSinceCommit = 0;
state.peakSinceCommit = 0; state.peakSinceCommit = 0;
state.stt.commit(); state.stt.commit();

View File

@@ -6,6 +6,13 @@ export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js"; export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js"; export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js";
export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent } from "../client/daemon-client.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 // Agent SDK types for CLI commands
export type { export type {

View File

@@ -41,6 +41,7 @@ const SpeechProviderIdSchema = z
const FeatureDictationSchema = z const FeatureDictationSchema = z
.object({ .object({
enabled: z.boolean().optional(),
stt: z stt: z
.object({ .object({
provider: SpeechProviderIdSchema.optional(), provider: SpeechProviderIdSchema.optional(),
@@ -54,6 +55,7 @@ const FeatureDictationSchema = z
const FeatureVoiceModeSchema = z const FeatureVoiceModeSchema = z
.object({ .object({
enabled: z.boolean().optional(),
llm: z llm: z
.object({ .object({
provider: z.enum(AGENT_PROVIDER_IDS as [string, ...string[]]).optional(), provider: z.enum(AGENT_PROVIDER_IDS as [string, ...string[]]).optional(),

View File

@@ -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<string, Array<(...args: any[]) => 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<typeof vi.fn>, message: string): boolean {
return mockFn.mock.calls.some((call) => call.some((arg) => arg === message));
}
describe("relay-transport control lifecycle", () => {
const controllers: Array<{ stop: () => Promise<void> }> = [];
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);
});
});

View File

@@ -37,6 +37,10 @@ type ControlMessage =
| { type: "ping" } | { type: "ping" }
| { type: "pong" }; | { 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 { function tryParseControlMessage(raw: unknown): ControlMessage | null {
try { try {
const text = const text =
@@ -76,7 +80,9 @@ export function startRelayTransport({
let reconnectAttempt = 0; let reconnectAttempt = 0;
const dataSockets = new Map<string, WebSocket>(); // clientId -> ws const dataSockets = new Map<string, WebSocket>(); // clientId -> ws
let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null; let controlKeepaliveInterval: ReturnType<typeof setInterval> | null = null;
let controlReadyTimeout: ReturnType<typeof setTimeout> | null = null;
let controlLastSeenAt = 0; let controlLastSeenAt = 0;
let controlConnectionSeq = 0;
const stop = async (): Promise<void> => { const stop = async (): Promise<void> => {
stopped = true; stopped = true;
@@ -88,6 +94,10 @@ export function startRelayTransport({
clearInterval(controlKeepaliveInterval); clearInterval(controlKeepaliveInterval);
controlKeepaliveInterval = null; controlKeepaliveInterval = null;
} }
if (controlReadyTimeout) {
clearTimeout(controlReadyTimeout);
controlReadyTimeout = null;
}
if (controlWs) { if (controlWs) {
try { try {
controlWs.close(); controlWs.close();
@@ -109,6 +119,7 @@ export function startRelayTransport({
const connectControl = (): void => { const connectControl = (): void => {
if (stopped) return; if (stopped) return;
const connectionId = ++controlConnectionSeq;
const url = buildRelayWebSocketUrl({ const url = buildRelayWebSocketUrl({
endpoint: relayEndpoint, endpoint: relayEndpoint,
serverId, serverId,
@@ -116,14 +127,46 @@ export function startRelayTransport({
}); });
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false }); const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
controlWs = socket; 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", () => { socket.on("open", () => {
reconnectAttempt = 0; if (controlWs !== socket) return;
controlLastSeenAt = Date.now(); controlLastSeenAt = Date.now();
if (controlKeepaliveInterval) { if (controlKeepaliveInterval) {
clearInterval(controlKeepaliveInterval); clearInterval(controlKeepaliveInterval);
controlKeepaliveInterval = null; 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(() => { controlKeepaliveInterval = setInterval(() => {
if (stopped) return; if (stopped) return;
if (controlWs !== socket) return; if (controlWs !== socket) return;
@@ -133,8 +176,11 @@ export function startRelayTransport({
const staleForMs = now - controlLastSeenAt; const staleForMs = now - controlLastSeenAt;
// If the control socket is half-open or silently dropped, ws may never emit "close". // 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. // Use app-level ping/pong to detect staleness and force a reconnect.
if (staleForMs > 90_000) { if (staleForMs > CONTROL_STALE_TIMEOUT_MS) {
relayLogger.warn({ url, staleForMs }, "relay_control_stale_terminating"); relayLogger.warn(
{ url, staleForMs, connectionId, staleTimeoutMs: CONTROL_STALE_TIMEOUT_MS },
"relay_control_stale_terminating"
);
try { try {
socket.terminate(); socket.terminate();
} catch { } catch {
@@ -146,40 +192,58 @@ export function startRelayTransport({
try { try {
socket.send(JSON.stringify({ type: "ping", ts: now })); socket.send(JSON.stringify({ type: "ping", ts: now }));
} catch (error) { } catch (error) {
relayLogger.warn({ err: error, url }, "relay_control_ping_send_failed"); relayLogger.warn({ err: error, url, connectionId }, "relay_control_ping_send_failed");
try { try {
socket.terminate(); socket.terminate();
} catch { } catch {
// ignore // ignore
} }
} }
}, 20_000); }, CONTROL_PING_INTERVAL_MS);
relayLogger.info({ url }, "relay_control_connected"); 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) => { socket.on("close", (code, reason) => {
if (controlWs !== socket) return;
relayLogger.warn( relayLogger.warn(
{ code, reason: reason?.toString?.(), url }, { code, reason: reason?.toString?.(), url, connectionId },
"relay_control_disconnected" "relay_control_disconnected"
); );
if (controlWs === socket) { controlWs = null;
controlWs = null;
}
if (controlKeepaliveInterval) { if (controlKeepaliveInterval) {
clearInterval(controlKeepaliveInterval); clearInterval(controlKeepaliveInterval);
controlKeepaliveInterval = null; controlKeepaliveInterval = null;
} }
if (controlReadyTimeout) {
clearTimeout(controlReadyTimeout);
controlReadyTimeout = null;
}
scheduleReconnect(); scheduleReconnect();
}); });
socket.on("error", (err) => { 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 // close event will schedule reconnect
}); });
socket.on("message", (data) => { socket.on("message", (data) => {
if (controlWs !== socket) return;
controlLastSeenAt = Date.now(); controlLastSeenAt = Date.now();
const msg = tryParseControlMessage(data); const msg = tryParseControlMessage(data);
if (msg) {
markControlReady();
}
if (!msg) return; if (!msg) return;
if (msg.type === "ping") { if (msg.type === "ping") {
try { try {

View File

@@ -26,7 +26,6 @@ import {
type ProjectPlacementPayload, type ProjectPlacementPayload,
} from "./messages.js"; } from "./messages.js";
import type { TerminalManager } from "../terminal/terminal-manager.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 { TTSManager } from "./agent/tts-manager.js";
import { STTManager } from "./agent/stt-manager.js"; import { STTManager } from "./agent/stt-manager.js";
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js"; import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
@@ -581,7 +580,7 @@ export class Session {
void this.initializeAgentMcp(); void this.initializeAgentMcp();
this.subscribeToAgentEvents(); 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; this.agentTools = (await this.agentMcpClient.tools()) as ToolSet;
const agentToolCount = Object.keys(this.agentTools ?? {}).length; const agentToolCount = Object.keys(this.agentTools ?? {}).length;
this.sessionLogger.info( this.sessionLogger.trace(
{ agentToolCount }, { agentToolCount },
`Agent MCP initialized with ${agentToolCount} tools` `Agent MCP initialized with ${agentToolCount} tools`
); );
@@ -941,9 +940,17 @@ export class Session {
agentId, agentId,
extractTimestamps(record) extractTimestamps(record)
); );
this.sessionLogger.info(
{ agentId, provider: record.provider },
"Agent resumed from persistence"
);
} else { } else {
const config = buildSessionConfig(record); const config = buildSessionConfig(record);
snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels }); 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); await this.agentManager.hydrateTimelineFromProvider(agentId);
@@ -1215,10 +1222,6 @@ export class Session {
); );
break; break;
case "git_diff_request":
await this.handleGitDiffRequest(msg.agentId, msg.requestId);
break;
case "checkout_status_request": case "checkout_status_request":
await this.handleCheckoutStatusRequest(msg); await this.handleCheckoutStatusRequest(msg);
break; break;
@@ -1267,10 +1270,6 @@ export class Session {
await this.handlePaseoWorktreeArchiveRequest(msg); await this.handlePaseoWorktreeArchiveRequest(msg);
break; break;
case "highlighted_diff_request":
await this.handleHighlightedDiffRequest(msg.agentId, msg.requestId);
break;
case "file_explorer_request": case "file_explorer_request":
await this.handleFileExplorerRequest(msg); await this.handleFileExplorerRequest(msg);
break; break;
@@ -2087,11 +2086,6 @@ export class Session {
agentId: string, agentId: string,
requestId: string requestId: string
): Promise<void> { ): Promise<void> {
this.sessionLogger.info(
{ agentId },
`Initializing agent ${agentId} on demand`
);
try { try {
const snapshot = await this.ensureAgentLoaded(agentId); const snapshot = await this.ensureAgentLoaded(agentId);
await this.forwardAgentUpdate(snapshot); await this.forwardAgentUpdate(snapshot);
@@ -2108,11 +2102,6 @@ export class Session {
requestId, requestId,
}, },
}); });
this.sessionLogger.info(
{ agentId, timelineSize, status: snapshot.lifecycle },
`Agent ${agentId} initialized with ${timelineSize} timeline item(s); status=${snapshot.lifecycle}`
);
} catch (error: any) { } catch (error: any) {
this.sessionLogger.error( this.sessionLogger.error(
{ err: error, agentId }, { 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<void> {
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( private async handleCheckoutStatusRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }> msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>
): Promise<void> { ): Promise<void> {
@@ -4190,12 +4119,13 @@ export class Session {
const { cwd, requestId } = msg; const { cwd, requestId } = msg;
try { try {
const status = await getPullRequestStatus(cwd); const prStatus = await getPullRequestStatus(cwd);
this.emit({ this.emit({
type: "checkout_pr_status_response", type: "checkout_pr_status_response",
payload: { payload: {
cwd, cwd,
status, status: prStatus.status,
githubFeaturesEnabled: prStatus.githubFeaturesEnabled,
error: null, error: null,
requestId, requestId,
}, },
@@ -4206,6 +4136,7 @@ export class Session {
payload: { payload: {
cwd, cwd,
status: null, status: null,
githubFeaturesEnabled: true,
error: this.toCheckoutError(error), error: this.toCheckoutError(error),
requestId, 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<void> {
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<string>();
const addedFiles = new Set<string>();
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 * Handle read-only file explorer requests scoped to an agent's cwd
*/ */
@@ -5871,7 +5571,7 @@ export class Session {
* Clean up session resources * Clean up session resources
*/ */
public async cleanup(): Promise<void> { public async cleanup(): Promise<void> {
this.sessionLogger.info("Cleaning up"); this.sessionLogger.trace("Cleaning up");
if (this.unsubscribeAgentEvents) { if (this.unsubscribeAgentEvents) {
this.unsubscribeAgentEvents(); this.unsubscribeAgentEvents();

View File

@@ -73,9 +73,10 @@ const LocalSpeechResolutionSchema = z.object({
function persistedLocalFeatureModel( function persistedLocalFeatureModel(
provider: RequestedSpeechProviders[keyof RequestedSpeechProviders]["provider"], provider: RequestedSpeechProviders[keyof RequestedSpeechProviders]["provider"],
enabled: boolean | undefined,
model: string | undefined model: string | undefined
): string | undefined { ): string | undefined {
if (provider !== "local") { if (provider !== "local" || enabled === false) {
return undefined; return undefined;
} }
return model; return model;
@@ -87,9 +88,12 @@ function shouldIncludeLocalProviderConfig(params: {
persisted: PersistedConfig; persisted: PersistedConfig;
}): boolean { }): boolean {
const localRequestedByFeature = const localRequestedByFeature =
params.providers.dictationStt.provider === "local" || (params.providers.dictationStt.enabled !== false &&
params.providers.voiceStt.provider === "local" || params.providers.dictationStt.provider === "local") ||
params.providers.voiceTts.provider === "local"; (params.providers.voiceStt.enabled !== false &&
params.providers.voiceStt.provider === "local") ||
(params.providers.voiceTts.enabled !== false &&
params.providers.voiceTts.provider === "local");
return ( return (
localRequestedByFeature || localRequestedByFeature ||
@@ -119,6 +123,7 @@ export function resolveLocalSpeechConfig(params: {
params.env.PASEO_DICTATION_LOCAL_STT_MODEL ?? params.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
persistedLocalFeatureModel( persistedLocalFeatureModel(
params.providers.dictationStt.provider, params.providers.dictationStt.provider,
params.providers.dictationStt.enabled,
params.persisted.features?.dictation?.stt?.model params.persisted.features?.dictation?.stt?.model
) ?? ) ??
DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_STT_MODEL,
@@ -126,6 +131,7 @@ export function resolveLocalSpeechConfig(params: {
params.env.PASEO_VOICE_LOCAL_STT_MODEL ?? params.env.PASEO_VOICE_LOCAL_STT_MODEL ??
persistedLocalFeatureModel( persistedLocalFeatureModel(
params.providers.voiceStt.provider, params.providers.voiceStt.provider,
params.providers.voiceStt.enabled,
params.persisted.features?.voiceMode?.stt?.model params.persisted.features?.voiceMode?.stt?.model
) ?? ) ??
DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_STT_MODEL,
@@ -133,6 +139,7 @@ export function resolveLocalSpeechConfig(params: {
params.env.PASEO_VOICE_LOCAL_TTS_MODEL ?? params.env.PASEO_VOICE_LOCAL_TTS_MODEL ??
persistedLocalFeatureModel( persistedLocalFeatureModel(
params.providers.voiceTts.provider, params.providers.voiceTts.provider,
params.providers.voiceTts.enabled,
params.persisted.features?.voiceMode?.tts?.model params.persisted.features?.voiceMode?.tts?.model
) ?? ) ??
DEFAULT_LOCAL_TTS_MODEL, DEFAULT_LOCAL_TTS_MODEL,

View File

@@ -88,13 +88,22 @@ function computeRequiredLocalModelIds(params: {
models: ResolvedLocalModels; models: ResolvedLocalModels;
}): LocalSpeechModelId[] { }): LocalSpeechModelId[] {
const ids = new Set<LocalSpeechModelId>(); const ids = new Set<LocalSpeechModelId>();
if (params.providers.dictationStt.provider === "local") { if (
params.providers.dictationStt.enabled !== false &&
params.providers.dictationStt.provider === "local"
) {
ids.add(params.models.dictationLocalSttModel); 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); 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); ids.add(params.models.voiceLocalTtsModel);
} }
return Array.from(ids); 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) { if (!localConfig) {
logger.warn( logger.warn(
{ configured: false }, { 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) { if (!localConfig) {
logger.warn( logger.warn(
{ configured: false }, { 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) { if (!localConfig) {
logger.warn( logger.warn(
{ configured: false }, { configured: false },

View File

@@ -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 { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -53,4 +53,46 @@ describe("sherpa model downloader", () => {
}) })
).rejects.toThrow(/auto-download/i); ).rejects.toThrow(/auto-download/i);
}); });
test("ensureSherpaOnnxModel logs artifact download progress", async () => {
const modelsDir = makeTmpDir();
const progressLogs: Array<Record<string, unknown>> = [];
const loggerWithSpy = {
child: () => loggerWithSpy,
info: (obj?: unknown, msg?: string) => {
if (msg === "Downloading model artifact" && obj && typeof obj === "object") {
progressLogs.push(obj as Record<string, unknown>);
}
},
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);
});
}); });

View File

@@ -1,7 +1,7 @@
import { createWriteStream } from "node:fs"; import { createWriteStream } from "node:fs";
import { mkdir, rename, rm, stat } from "node:fs/promises"; import { mkdir, rename, rm, stat } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { Readable } from "node:stream"; import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import type pino from "pino"; import type pino from "pino";
@@ -39,7 +39,31 @@ async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Prom
return true; return true;
} }
async function downloadToFile(url: string, outputPath: string, logger: pino.Logger): Promise<void> { 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<void> {
const { url, outputPath, logger, modelId, artifact } = options;
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`); 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()}`; const tmpPath = `${outputPath}.tmp-${Date.now()}`;
await mkdir(path.dirname(outputPath), { recursive: true }); await mkdir(path.dirname(outputPath), { recursive: true });
const total = Number(res.headers.get("content-length") ?? "0"); const nodeStream = Readable.fromWeb(res.body as any);
let downloaded = 0; const totalBytes = parseContentLength(res);
let lastLoggedBucket = -1;
const nodeStream = Readable.fromWeb(res.body as any).on("data", (chunk: Buffer) => { let downloadedBytes = 0;
downloaded += chunk.length; let lastLoggedPct = -1;
if (total > 0) { let lastLoggedBytes = 0;
const pct = Math.floor((downloaded / total) * 100); let lastLoggedAt = 0;
const bucket = Math.min(100, Math.floor(pct / 10) * 10);
if (bucket >= 0 && bucket <= 100 && bucket !== lastLoggedBucket) { const logProgress = (force: boolean): void => {
lastLoggedBucket = bucket; const pct =
logger.info({ pct: bucket, downloaded, total }, "Downloading model artifact"); 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)); try {
await rename(tmpPath, outputPath); 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<void> { async function extractTarArchive(archivePath: string, destDir: string): Promise<void> {
@@ -114,60 +182,103 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption
); );
} }
if (spec.archiveUrl) { logger.info({ modelsDir: options.modelsDir }, "Starting model download");
logger.info({ modelsDir: options.modelsDir, url: spec.archiveUrl }, "Model files missing; downloading");
const downloadsDir = path.join(options.modelsDir, ".downloads"); try {
const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname); if (spec.archiveUrl) {
const archivePath = path.join(downloadsDir, archiveFilename); 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))) { if (!(await isNonEmptyFile(archivePath))) {
await downloadToFile(spec.archiveUrl, archivePath, logger); await downloadToFile({
} else { url: spec.archiveUrl,
logger.info({ archivePath }, "Using cached archive"); outputPath: archivePath,
} logger,
modelId: options.modelId,
await extractTarArchive(archivePath, options.modelsDir); artifact: archiveFilename,
});
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;
} }
await downloadToFile(file.url, dst, logger);
}
if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) { logger.info(
throw new Error( {
`Downloaded files for ${options.modelId}, but required files are still missing in ${modelDir}.` 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"); if (spec.downloadFiles && spec.downloadFiles.length > 0) {
return modelDir; 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: { export async function ensureSherpaOnnxModels(options: {

View File

@@ -74,27 +74,32 @@ export function resolveOpenAiSpeechConfig(params: {
params.persisted.features?.dictation?.stt?.confidenceThreshold, params.persisted.features?.dictation?.stt?.confidenceThreshold,
sttModel: sttModel:
params.env.STT_MODEL ?? 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 ? params.persisted.features?.voiceMode?.stt?.model
: undefined) ?? : undefined) ??
(params.providers.dictationStt.provider === "openai" (params.providers.dictationStt.enabled !== false &&
params.providers.dictationStt.provider === "openai"
? params.persisted.features?.dictation?.stt?.model ? params.persisted.features?.dictation?.stt?.model
: undefined), : undefined),
ttsVoice: ttsVoice:
params.env.TTS_VOICE ?? 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 ? params.persisted.features?.voiceMode?.tts?.voice
: undefined) ?? : undefined) ??
"alloy", "alloy",
ttsModel: ttsModel:
params.env.TTS_MODEL ?? 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 ? params.persisted.features?.voiceMode?.tts?.model
: undefined) ?? : undefined) ??
DEFAULT_OPENAI_TTS_MODEL, DEFAULT_OPENAI_TTS_MODEL,
realtimeTranscriptionModel: realtimeTranscriptionModel:
params.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ?? 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 ? params.persisted.features?.dictation?.stt?.model
: undefined) ?? : undefined) ??
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL, DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,

View File

@@ -60,13 +60,22 @@ export function validateOpenAiCredentialRequirements(params: {
const openAiCredentials = resolveOpenAiCredentials(openaiConfig); const openAiCredentials = resolveOpenAiCredentials(openaiConfig);
const missingOpenAiCredentialsFor: string[] = []; 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"); 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"); missingOpenAiCredentialsFor.push("voice.tts");
} }
if ( if (
providers.dictationStt.enabled !== false &&
providers.dictationStt.provider === "openai" && providers.dictationStt.provider === "openai" &&
!openAiCredentials.openaiDictationApiKey !openAiCredentials.openaiDictationApiKey
) { ) {
@@ -104,10 +113,18 @@ export function initializeOpenAiSpeechServices(params: {
let ttsService = existing.ttsService; let ttsService = existing.ttsService;
let dictationSttService = existing.dictationSttService; let dictationSttService = existing.dictationSttService;
const needsOpenAiStt = !sttService && providers.voiceStt.provider === "openai"; const needsOpenAiStt =
const needsOpenAiTts = !ttsService && providers.voiceTts.provider === "openai"; !sttService &&
providers.voiceStt.enabled !== false &&
providers.voiceStt.provider === "openai";
const needsOpenAiTts =
!ttsService &&
providers.voiceTts.enabled !== false &&
providers.voiceTts.provider === "openai";
const needsOpenAiDictation = const needsOpenAiDictation =
!dictationSttService && providers.dictationStt.provider === "openai"; !dictationSttService &&
providers.dictationStt.enabled !== false &&
providers.dictationStt.provider === "openai";
if ( if (
(needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) && (needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) &&

View File

@@ -21,14 +21,17 @@ describe("resolveSpeechConfig", () => {
expect(result.speech.providers.dictationStt).toEqual({ expect(result.speech.providers.dictationStt).toEqual({
provider: "local", provider: "local",
explicit: false, explicit: false,
enabled: true,
}); });
expect(result.speech.providers.voiceStt).toEqual({ expect(result.speech.providers.voiceStt).toEqual({
provider: "local", provider: "local",
explicit: false, explicit: false,
enabled: true,
}); });
expect(result.speech.providers.voiceTts).toEqual({ expect(result.speech.providers.voiceTts).toEqual({
provider: "local", provider: "local",
explicit: false, explicit: false,
enabled: true,
}); });
expect(result.speech.local).toEqual({ expect(result.speech.local).toEqual({
modelsDir: path.join(paseoHome, "models", "local-speech"), modelsDir: path.join(paseoHome, "models", "local-speech"),
@@ -91,14 +94,17 @@ describe("resolveSpeechConfig", () => {
expect(result.speech.providers.dictationStt).toEqual({ expect(result.speech.providers.dictationStt).toEqual({
provider: "local", provider: "local",
explicit: true, explicit: true,
enabled: true,
}); });
expect(result.speech.providers.voiceStt).toEqual({ expect(result.speech.providers.voiceStt).toEqual({
provider: "openai", provider: "openai",
explicit: true, explicit: true,
enabled: true,
}); });
expect(result.speech.providers.voiceTts).toEqual({ expect(result.speech.providers.voiceTts).toEqual({
provider: "local", provider: "local",
explicit: true, explicit: true,
enabled: true,
}); });
expect(result.speech.local?.models.dictationStt).toBe("zipformer-bilingual-zh-en-2023-02-20"); 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"); 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.voiceTts).toBe("kokoro-en-v0_19");
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(0); 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,
});
});
}); });

View File

@@ -17,6 +17,25 @@ const OptionalSpeechProviderSchema = z
.pipe(SpeechProviderIdSchema) .pipe(SpeechProviderIdSchema)
.optional(); .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({ const RequestedSpeechProvidersSchema = z.object({
dictationStt: OptionalSpeechProviderSchema.default("local"), dictationStt: OptionalSpeechProviderSchema.default("local"),
voiceStt: OptionalSpeechProviderSchema.default("local"), voiceStt: OptionalSpeechProviderSchema.default("local"),
@@ -29,10 +48,12 @@ function resolveRequestedSpeechProviders(params: {
}): RequestedSpeechProviders { }): RequestedSpeechProviders {
const resolveFeatureProvider = ( const resolveFeatureProvider = (
configuredValue: string | undefined, configuredValue: string | undefined,
parsedValue: z.infer<typeof SpeechProviderIdSchema> parsedValue: z.infer<typeof SpeechProviderIdSchema>,
enabled: boolean
): RequestedSpeechProvider => ({ ): RequestedSpeechProvider => ({
provider: parsedValue, provider: parsedValue,
explicit: configuredValue !== undefined, explicit: configuredValue !== undefined,
enabled,
}); });
const dictationSttProviderFromConfig = const dictationSttProviderFromConfig =
@@ -44,6 +65,14 @@ function resolveRequestedSpeechProviders(params: {
const voiceTtsProviderFromConfig = const voiceTtsProviderFromConfig =
params.env.PASEO_VOICE_TTS_PROVIDER ?? params.env.PASEO_VOICE_TTS_PROVIDER ??
params.persisted.features?.voiceMode?.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({ const parsed = RequestedSpeechProvidersSchema.parse({
dictationStt: dictationSttProviderFromConfig ?? "local", dictationStt: dictationSttProviderFromConfig ?? "local",
@@ -54,15 +83,18 @@ function resolveRequestedSpeechProviders(params: {
return { return {
dictationStt: resolveFeatureProvider( dictationStt: resolveFeatureProvider(
dictationSttProviderFromConfig, dictationSttProviderFromConfig,
parsed.dictationStt parsed.dictationStt,
dictationEnabled
), ),
voiceStt: resolveFeatureProvider( voiceStt: resolveFeatureProvider(
voiceSttProviderFromConfig, voiceSttProviderFromConfig,
parsed.voiceStt parsed.voiceStt,
voiceModeEnabled
), ),
voiceTts: resolveFeatureProvider( voiceTts: resolveFeatureProvider(
voiceTtsProviderFromConfig, voiceTtsProviderFromConfig,
parsed.voiceTts parsed.voiceTts,
voiceModeEnabled
), ),
}; };
} }

View File

@@ -23,9 +23,9 @@ function resolveRequestedSpeechProviders(
} }
return { return {
dictationStt: { provider: "local", explicit: false }, dictationStt: { provider: "local", explicit: false, enabled: true },
voiceStt: { provider: "local", explicit: false }, voiceStt: { provider: "local", explicit: false, enabled: true },
voiceTts: { provider: "local", explicit: false }, voiceTts: { provider: "local", explicit: false, enabled: true },
}; };
} }
@@ -59,9 +59,18 @@ export async function initializeSpeechRuntime(params: {
logger.info( logger.info(
{ {
requestedProviders: { requestedProviders: {
dictationStt: providers.dictationStt.provider, dictationStt: {
voiceStt: providers.voiceStt.provider, provider: providers.dictationStt.provider,
voiceTts: providers.voiceTts.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: { availability: {
openai: getOpenAiSpeechAvailability(openaiConfig), openai: getOpenAiSpeechAvailability(openaiConfig),
@@ -99,9 +108,11 @@ export async function initializeSpeechRuntime(params: {
: "openai", : "openai",
}; };
const unavailableFeatures = [ const unavailableFeatures = [
!openAiSpeech.dictationSttService ? "dictation.stt" : null, providers.dictationStt.enabled !== false && !openAiSpeech.dictationSttService
!openAiSpeech.sttService ? "voice.stt" : null, ? "dictation.stt"
!openAiSpeech.ttsService ? "voice.tts" : null, : 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); ].filter((feature): feature is string => feature !== null);
const explicitlyConfiguredUnavailableFeatures = unavailableFeatures.filter((feature) => { const explicitlyConfiguredUnavailableFeatures = unavailableFeatures.filter((feature) => {
if (feature === "dictation.stt") { if (feature === "dictation.stt") {
@@ -117,9 +128,18 @@ export async function initializeSpeechRuntime(params: {
logger.error( logger.error(
{ {
requestedProviders: { requestedProviders: {
dictationStt: providers.dictationStt.provider, dictationStt: {
voiceStt: providers.voiceStt.provider, provider: providers.dictationStt.provider,
voiceTts: providers.voiceTts.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: { explicitProviders: {
dictationStt: providers.dictationStt.explicit, dictationStt: providers.dictationStt.explicit,
@@ -140,9 +160,18 @@ export async function initializeSpeechRuntime(params: {
logger.warn( logger.warn(
{ {
requestedProviders: { requestedProviders: {
dictationStt: providers.dictationStt.provider, dictationStt: {
voiceStt: providers.voiceStt.provider, provider: providers.dictationStt.provider,
voiceTts: providers.voiceTts.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: { explicitProviders: {
dictationStt: providers.dictationStt.explicit, dictationStt: providers.dictationStt.explicit,

View File

@@ -6,6 +6,7 @@ export type SpeechProviderId = z.infer<typeof SpeechProviderIdSchema>;
export const RequestedSpeechProviderSchema = z.object({ export const RequestedSpeechProviderSchema = z.object({
provider: SpeechProviderIdSchema, provider: SpeechProviderIdSchema,
explicit: z.boolean(), explicit: z.boolean(),
enabled: z.boolean().optional(),
}); });
export type RequestedSpeechProvider = z.infer<typeof RequestedSpeechProviderSchema>; export type RequestedSpeechProvider = z.infer<typeof RequestedSpeechProviderSchema>;

View File

@@ -279,7 +279,7 @@ export class VoiceAssistantWebSocketServer {
}) })
); );
connectionLogger.info( connectionLogger.trace(
{ clientId, totalSessions: this.sessions.size }, { clientId, totalSessions: this.sessions.size },
"Client connected" "Client connected"
); );
@@ -319,7 +319,7 @@ export class VoiceAssistantWebSocketServer {
const session = this.sessions.get(ws); const session = this.sessions.get(ws);
if (!session) return; if (!session) return;
connectionLogger.info( connectionLogger.trace(
{ clientId, totalSessions: this.sessions.size - 1 }, { clientId, totalSessions: this.sessions.size - 1 },
"Client disconnected" "Client disconnected"
); );
@@ -386,28 +386,12 @@ export class VoiceAssistantWebSocketServer {
const message = parsedMessage.data; 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") { if (message.type === "ping") {
this.sendToClient(ws, { type: "pong" }); this.sendToClient(ws, { type: "pong" });
return; return;
} }
if (message.type === "recording_state") { if (message.type === "recording_state") {
this.logger.debug({ isRecording: message.isRecording }, "Recording state");
return; return;
} }
@@ -418,17 +402,6 @@ export class VoiceAssistantWebSocketServer {
} }
if (message.type === "session") { 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); await session.handleMessage(message.message);
} }
} catch (error) { } catch (error) {
@@ -502,23 +475,11 @@ export class VoiceAssistantWebSocketServer {
} { } {
const activity = session.getClientActivity(); const activity = session.getClientActivity();
if (!activity) { if (!activity) {
this.logger.debug("getClientActivityState: no activity for session");
return { deviceType: null, focusedAgentId: null, isStale: true, appVisible: false }; return { deviceType: null, focusedAgentId: null, isStale: true, appVisible: false };
} }
const now = Date.now(); const now = Date.now();
const ageMs = now - activity.lastActivityAt.getTime(); const ageMs = now - activity.lastActivityAt.getTime();
const isStale = ageMs >= this.ACTIVITY_THRESHOLD_MS; 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 { return {
deviceType: activity.deviceType, deviceType: activity.deviceType,
focusedAgentId: activity.focusedAgentId, focusedAgentId: activity.focusedAgentId,
@@ -603,16 +564,6 @@ export class VoiceAssistantWebSocketServer {
const allStates = clientEntries.map((e) => e.state); 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( const hasActiveWebClient = allStates.some(
(state) => state.deviceType === "web" && !state.isStale (state) => state.deviceType === "web" && !state.isStale
); );
@@ -627,11 +578,6 @@ export class VoiceAssistantWebSocketServer {
!hasActiveWebClient && !hasActiveWebClient &&
!hasActiveMobileForegroundClient; !hasActiveMobileForegroundClient;
this.logger.debug(
{ hasActiveWebClient, hasActiveMobileForegroundClient, shouldSendPush },
"Push gating check"
);
if (shouldSendPush) { if (shouldSendPush) {
const tokens = this.pushTokenStore.getAllTokens(); const tokens = this.pushTokenStore.getAllTokens();
this.logger.info({ tokenCount: tokens.length }, "Sending push notification"); this.logger.info({ tokenCount: tokens.length }, "Sending push notification");

View File

@@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest";
import { import {
AgentStreamMessageSchema, AgentStreamMessageSchema,
AgentStreamSnapshotMessageSchema, AgentStreamSnapshotMessageSchema,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
WSOutboundMessageSchema, WSOutboundMessageSchema,
} from "./messages.js"; } from "./messages.js";
@@ -64,4 +66,44 @@ describe("shared messages stream parsing", () => {
}); });
expect(wrapped.success).toBe(false); 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);
});
}); });

View File

@@ -696,12 +696,6 @@ export const AgentPermissionResponseMessageSchema = z.object({
response: AgentPermissionResponseSchema, response: AgentPermissionResponseSchema,
}); });
export const GitDiffRequestSchema = z.object({
type: z.literal("git_diff_request"),
agentId: z.string(),
requestId: z.string(),
});
const CheckoutErrorCodeSchema = z.enum([ const CheckoutErrorCodeSchema = z.enum([
"NOT_GIT_REPO", "NOT_GIT_REPO",
"NOT_ALLOWED", "NOT_ALLOWED",
@@ -837,12 +831,6 @@ const ParsedDiffFileSchema = z.object({
status: z.enum(["ok", "too_large", "binary"]).optional(), 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({ const FileExplorerEntrySchema = z.object({
name: z.string(), name: z.string(),
path: z.string(), path: z.string(),
@@ -1010,7 +998,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SetAgentModelRequestMessageSchema, SetAgentModelRequestMessageSchema,
SetAgentThinkingRequestMessageSchema, SetAgentThinkingRequestMessageSchema,
AgentPermissionResponseMessageSchema, AgentPermissionResponseMessageSchema,
GitDiffRequestSchema,
CheckoutStatusRequestSchema, CheckoutStatusRequestSchema,
SubscribeCheckoutDiffRequestSchema, SubscribeCheckoutDiffRequestSchema,
UnsubscribeCheckoutDiffRequestSchema, UnsubscribeCheckoutDiffRequestSchema,
@@ -1023,7 +1010,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ValidateBranchRequestSchema, ValidateBranchRequestSchema,
PaseoWorktreeListRequestSchema, PaseoWorktreeListRequestSchema,
PaseoWorktreeArchiveRequestSchema, PaseoWorktreeArchiveRequestSchema,
HighlightedDiffRequestSchema,
FileExplorerRequestSchema, FileExplorerRequestSchema,
ProjectIconRequestSchema, ProjectIconRequestSchema,
FileDownloadTokenRequestSchema, 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({ const AheadBehindSchema = z.object({
ahead: z.number(), ahead: z.number(),
behind: z.number(), behind: z.number(),
@@ -1564,6 +1540,7 @@ export const CheckoutPrStatusResponseSchema = z.object({
payload: z.object({ payload: z.object({
cwd: z.string(), cwd: z.string(),
status: CheckoutPrStatusSchema.nullable(), status: CheckoutPrStatusSchema.nullable(),
githubFeaturesEnabled: z.boolean(),
error: CheckoutErrorSchema.nullable(), error: CheckoutErrorSchema.nullable(),
requestId: z.string(), 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({ export const FileExplorerResponseSchema = z.object({
type: z.literal("file_explorer_response"), type: z.literal("file_explorer_response"),
payload: z.object({ payload: z.object({
@@ -1834,7 +1801,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
AgentPermissionResolvedMessageSchema, AgentPermissionResolvedMessageSchema,
AgentDeletedMessageSchema, AgentDeletedMessageSchema,
AgentArchivedMessageSchema, AgentArchivedMessageSchema,
GitDiffResponseSchema,
CheckoutStatusResponseSchema, CheckoutStatusResponseSchema,
SubscribeCheckoutDiffResponseSchema, SubscribeCheckoutDiffResponseSchema,
CheckoutDiffUpdateSchema, CheckoutDiffUpdateSchema,
@@ -1847,7 +1813,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ValidateBranchResponseSchema, ValidateBranchResponseSchema,
PaseoWorktreeListResponseSchema, PaseoWorktreeListResponseSchema,
PaseoWorktreeArchiveResponseSchema, PaseoWorktreeArchiveResponseSchema,
HighlightedDiffResponseSchema,
FileExplorerResponseSchema, FileExplorerResponseSchema,
ProjectIconResponseSchema, ProjectIconResponseSchema,
FileDownloadTokenResponseSchema, FileDownloadTokenResponseSchema,
@@ -1944,8 +1909,6 @@ export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessa
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>; export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>; export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>; export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>; export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>; export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
export type SubscribeCheckoutDiffRequest = z.infer< export type SubscribeCheckoutDiffRequest = z.infer<
@@ -1976,8 +1939,6 @@ export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSc
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>; export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>; export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>; export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
export type HighlightedDiffRequest = z.infer<typeof HighlightedDiffRequestSchema>;
export type HighlightedDiffResponse = z.infer<typeof HighlightedDiffResponseSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>; export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>; export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>; export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;

View File

@@ -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<typeof import("child_process")>("child_process");
return {
...actual,
spawn: (...args: Parameters<typeof actual.spawn>) => {
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);
});
});

View File

@@ -1,11 +1,12 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execSync } from "child_process"; 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 { join } from "path";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { import {
commitAll, commitAll,
getCheckoutDiff, getCheckoutDiff,
getPullRequestStatus,
getCheckoutStatus, getCheckoutStatus,
getCheckoutStatusLite, getCheckoutStatusLite,
mergeToBase, 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 () => { it("short-circuits untracked binary files", async () => {
const binaryPath = join(repoDir, "blob.bin"); const binaryPath = join(repoDir, "blob.bin");
writeFileSync(binaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00, 0x7f, 0x00])); 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`); 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 () => { it("returns typed MergeConflictError on merge conflicts", async () => {
const conflictFile = join(repoDir, "conflict.txt"); const conflictFile = join(repoDir, "conflict.txt");
writeFileSync(conflictFile, "base\n"); writeFileSync(conflictFile, "base\n");

View File

@@ -184,35 +184,81 @@ async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string
type FileStat = { additions: number; deletions: number; isBinary: boolean } | null; type FileStat = { additions: number; deletions: number; isBinary: boolean } | null;
async function tryGetNumstat( function normalizeNumstatPath(pathField: string): string {
cwd: string, const braceRenameMatch = pathField.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
args: string[] if (braceRenameMatch) {
): Promise<FileStat> { const [, prefix, , renamed, suffix] = braceRenameMatch;
try { return `${prefix}${renamed}${suffix}`;
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;
} }
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<Map<string, FileStat>> {
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<string, FileStat>();
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 { export class NotGitRepoError extends Error {
@@ -784,49 +830,32 @@ function buildPlaceholderParsedDiffFile(
}; };
} }
async function getPerFileDiffText( async function getUntrackedDiffText(
cwd: string, cwd: string,
ref: string,
change: CheckoutFileChange change: CheckoutFileChange
): Promise<{ text: string; truncated: boolean; stat: FileStat }> { ): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
if (change.isUntracked) { try {
try { const inspected = await inspectUntrackedFile(cwd, change.path);
const inspected = await inspectUntrackedFile(cwd, change.path); if (inspected.stat?.isBinary || inspected.truncated) {
if (inspected.stat?.isBinary || inspected.truncated) { return { text: "", truncated: inspected.truncated, stat: inspected.stat };
return { text: "", truncated: inspected.truncated, stat: inspected.stat };
}
} catch {
// Fall through to git diff path if metadata probing fails.
} }
} catch {
const result = await spawnLimitedText({ // Fall through to git diff path if metadata probing fails.
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 };
} }
const result = await spawnLimitedText({ const result = await spawnLimitedText({
cmd: "git", cmd: "git",
args: ["diff", ref, "--", change.path], args: ["diff", "--no-index", "/dev/null", "--", change.path],
cwd, cwd,
env: READ_ONLY_GIT_ENV, env: READ_ONLY_GIT_ENV,
maxBytes: PER_FILE_DIFF_MAX_BYTES, 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( export async function getCheckoutStatus(
@@ -966,8 +995,111 @@ export async function getCheckoutDiff(
} }
}; };
for (const change of changes) { const trackedChanges = changes.filter((change) => !change.isUntracked);
const { text, truncated, stat } = await getPerFileDiffText(cwd, refForDiff, change); const untrackedChanges = changes.filter((change) => change.isUntracked === true);
const trackedNumstatByPath =
trackedChanges.length > 0 ? await getTrackedNumstatByPath(cwd, refForDiff) : new Map<string, FileStat>();
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 (!compare.includeStructured) {
if (stat?.isBinary) { if (stat?.isBinary) {
@@ -977,9 +1109,6 @@ export async function getCheckoutDiff(
} else { } else {
appendDiff(text); appendDiff(text);
} }
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
break;
}
continue; continue;
} }
@@ -1268,6 +1397,11 @@ export interface PullRequestStatus {
headRefName: string; headRefName: string;
} }
export interface PullRequestStatusResult {
status: PullRequestStatus | null;
githubFeaturesEnabled: boolean;
}
async function ensureGhAvailable(cwd: string): Promise<void> { async function ensureGhAvailable(cwd: string): Promise<void> {
try { try {
await execAsync("gh --version", { cwd }); await execAsync("gh --version", { cwd });
@@ -1276,6 +1410,27 @@ async function ensureGhAvailable(cwd: string): Promise<void> {
} }
} }
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<string | null> { async function resolveGitHubRepo(cwd: string): Promise<string | null> {
try { try {
const { stdout } = await execAsync("git config --get remote.origin.url", { 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 }; return { url: parsed.url, number: parsed.number };
} }
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatus | null> { export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatusResult> {
await requireGitRepo(cwd); await requireGitRepo(cwd);
await ensureGhAvailable(cwd);
const repo = await resolveGitHubRepo(cwd); const repo = await resolveGitHubRepo(cwd);
const head = await getCurrentBranch(cwd); const head = await getCurrentBranch(cwd);
if (!repo || !head) { 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 owner = repo.split("/")[0];
const { stdout } = await execFileAsync( let stdout: string;
"gh", try {
[ ({ stdout } = await execFileAsync(
"api", "gh",
`repos/${repo}/pulls`, [
"-X", "api",
"GET", `repos/${repo}/pulls`,
"-F", "-X",
`head=${owner}:${head}`, "GET",
"-F", "-F",
"state=open", `head=${owner}:${head}`,
], "-F",
{ cwd } "state=open",
); ],
{ cwd }
));
} catch (error) {
if (isGhAuthError(error)) {
return {
status: null,
githubFeaturesEnabled: false,
};
}
throw error;
}
const parsed = JSON.parse(stdout.trim()); const parsed = JSON.parse(stdout.trim());
const current = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null; const current = Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null;
if (!current) { if (!current) {
return null; return {
status: null,
githubFeaturesEnabled: true,
};
} }
return { return {
url: current.html_url ?? current.url, status: {
title: current.title, url: current.html_url ?? current.url,
state: current.state, title: current.title,
baseRefName: current.base?.ref ?? "", state: current.state,
headRefName: current.head?.ref ?? head, baseRefName: current.base?.ref ?? "",
headRefName: current.head?.ref ?? head,
},
githubFeaturesEnabled: true,
}; };
} }